// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { Kraiken } from "../src/Kraiken.sol"; import { LiquidityManager } from "../src/LiquidityManager.sol"; import { Optimizer } from "../src/Optimizer.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 { BearMarketOptimizer } from "../test/mocks/BearMarketOptimizer.sol"; import { BullMarketOptimizer } from "../test/mocks/BullMarketOptimizer.sol"; import { ExtremeOptimizer } from "../test/mocks/ExtremeOptimizer.sol"; import { MaliciousOptimizer } from "../test/mocks/MaliciousOptimizer.sol"; import { NeutralMarketOptimizer } from "../test/mocks/NeutralMarketOptimizer.sol"; import { WhaleOptimizer } from "../test/mocks/WhaleOptimizer.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"; contract StreamlinedFuzzing is Script { // Test environment TestEnvironment testEnv; IUniswapV3Factory factory; IUniswapV3Pool pool; IWETH9 weth; Kraiken kraiken; Stake stake; LiquidityManager lm; SwapExecutor swapExecutor; bool token0isWeth; // Actors address trader = makeAddr("trader"); address staker = makeAddr("staker"); address fees = makeAddr("fees"); // Staking tracking uint256[] public activePositionIds; uint256 totalStakesAttempted; uint256 totalStakesSucceeded; // CSV filename for current run string csvFilename; // Track cumulative fees uint256 totalFees0; uint256 totalFees1; // Track recentering uint256 lastRecenterBlock; // Config uint256 cfgBuyBias; uint256 cfgMinBuy; uint256 cfgMaxBuy; bool cfgUncapped; function run() public { // Get configuration from environment uint256 numRuns = vm.envOr("FUZZING_RUNS", uint256(20)); uint256 tradesPerRun = vm.envOr("TRADES_PER_RUN", uint256(15)); bool enableStaking = vm.envOr("ENABLE_STAKING", true); cfgBuyBias = vm.envOr("BUY_BIAS", uint256(50)); uint256 stakingBias = vm.envOr("STAKING_BIAS", uint256(80)); string memory optimizerClass = vm.envOr("OPTIMIZER_CLASS", string("BullMarketOptimizer")); cfgUncapped = vm.envOr("UNCAPPED_SWAPS", true); cfgMinBuy = vm.envOr("MIN_BUY_ETH", uint256(20)); cfgMaxBuy = vm.envOr("MAX_BUY_ETH", uint256(80)); console2.log("=== Streamlined Fuzzing Analysis ==="); console2.log("Optimizer:", optimizerClass); console2.log("Uncapped swaps:", cfgUncapped); console2.log("Trade range (ETH):", cfgMinBuy, cfgMaxBuy); // Deploy factory once testEnv = new TestEnvironment(fees); factory = UniswapHelpers.deployUniswapFactory(); // Generate unique 4-character scenario ID string memory scenarioCode = _generateScenarioId(); // Setup environment ONCE — all runs share it so VWAP accumulates _setupEnvironment(optimizerClass, true); // Track LM ETH across the entire session uint256 lmEthStart = address(lm).balance + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)); console2.log("LM starting ETH:", lmEthStart / 1e18, "ETH"); // Run fuzzing scenarios — same environment, VWAP carries over for (uint256 runIndex = 0; runIndex < numRuns; runIndex++) { string memory runId = string(abi.encodePacked(scenarioCode, "-", _padNumber(runIndex, 3))); console2.log("\nRun:", runId); // Initialize CSV file for this run csvFilename = string(abi.encodePacked("analysis/fuzz-", runId, ".csv")); string memory header = "action,amount,tick,floor_lower,floor_upper,floor_liq,anchor_lower,anchor_upper,anchor_liq,discovery_lower,discovery_upper,discovery_liq,eth_balance,kraiken_balance,vwap,fees_eth,fees_kraiken,recenter\n"; vm.writeFile(csvFilename, header); // Reset tracking for CSV totalFees0 = 0; totalFees1 = 0; lastRecenterBlock = block.number; // Reset trader — burn any leftover WETH/ETH from previous run 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); } // Fund trader fresh for this run uint256 traderFund = 200 ether; vm.deal(trader, traderFund); vm.prank(trader); weth.deposit{ value: traderFund }(); // Initial state _recordState("INIT", 0); // Execute trades for (uint256 i = 0; i < tradesPerRun; i++) { // Check for recenter opportunity on average every 3 trades uint256 recenterRand = uint256(keccak256(abi.encodePacked(runIndex, i, "recenter"))) % 3; if (recenterRand == 0) { _tryRecenter(); } // Determine trade based on bias uint256 rand = uint256(keccak256(abi.encodePacked(runIndex, i))) % 100; if (rand < cfgBuyBias) { _executeBuy(runIndex, i); } else { _executeSell(runIndex, i); } // Staking operations if enabled if (enableStaking && i % 5 == 0) { _executeStakingOperation(runIndex, i, stakingBias); } } // Final recenter + liquidate _tryRecenter(); _liquidateTraderHoldings(); _recordState("FINAL", 0); // Report per-run PnL uint256 traderEthNow = weth.balanceOf(trader); uint256 lmEthNow = address(lm).balance + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)); if (traderEthNow > traderFund) { console2.log(" TRADER PROFIT:", (traderEthNow - traderFund) / 1e15, "finney"); } console2.log(" LM ETH (wei):", lmEthNow, _signedDelta(lmEthNow, lmEthStart)); } uint256 lmEthEnd = address(lm).balance + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)); console2.log("\n=== Analysis Complete ==="); console2.log("LM ETH start:", lmEthStart / 1e18, "final (ETH):", lmEthEnd / 1e18); if (lmEthEnd < lmEthStart) { console2.log("LM LOST ETH:", (lmEthStart - lmEthEnd) / 1e15, "finney"); } console2.log("Generated", numRuns, "CSV files with prefix:", scenarioCode); } function _signedDelta(uint256 current, uint256 start) internal pure returns (string memory) { if (current >= start) { return string(abi.encodePacked("+", vm.toString((current - start) / 1e15), " finney")); } else { return string(abi.encodePacked("-", vm.toString((start - current) / 1e15), " finney")); } } function _setupEnvironment(string memory optimizerClass, bool wethIsToken0) internal { address optimizer = _deployOptimizer(optimizerClass); (factory, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, wethIsToken0, fees, optimizer); // Deploy swap executor — uncapped by default for exploit testing swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, cfgUncapped); // Fund liquidity manager vm.deal(address(lm), 200 ether); vm.prank(address(lm)); weth.deposit{ value: 100 ether }(); // Initial recenter to set positions vm.prank(fees); try lm.recenter() returns (bool isUp) { console2.log("Initial recenter successful, isUp:", isUp); } catch Error(string memory reason) { console2.log("Initial recenter failed:", reason); } catch { console2.log("Initial recenter failed with unknown error"); } } function _deployOptimizer(string memory optimizerClass) internal returns (address) { if (keccak256(bytes(optimizerClass)) == keccak256(bytes("BullMarketOptimizer"))) { return address(new BullMarketOptimizer()); } else if (keccak256(bytes(optimizerClass)) == keccak256(bytes("BearMarketOptimizer"))) { return address(new BearMarketOptimizer()); } else if (keccak256(bytes(optimizerClass)) == keccak256(bytes("NeutralMarketOptimizer"))) { return address(new NeutralMarketOptimizer()); } else if (keccak256(bytes(optimizerClass)) == keccak256(bytes("WhaleOptimizer"))) { return address(new WhaleOptimizer()); } else if (keccak256(bytes(optimizerClass)) == keccak256(bytes("ExtremeOptimizer"))) { return address(new ExtremeOptimizer()); } else if (keccak256(bytes(optimizerClass)) == keccak256(bytes("MaliciousOptimizer"))) { return address(new MaliciousOptimizer()); } else { return address(new BullMarketOptimizer()); } } function _executeBuy(uint256 runIndex, uint256 tradeIndex) internal { uint256 range = cfgMaxBuy - cfgMinBuy; uint256 amount = (cfgMinBuy * 1 ether) + (uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "buy"))) % (range * 1 ether)); if (amount == 0 || weth.balanceOf(trader) < amount) return; vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) returns (uint256 actualAmount) { if (actualAmount == 0) { console2.log("Buy returned 0, requested:", amount / 1e18); } _recordState("BUY", actualAmount); } catch { _recordState("BUY_FAIL", amount); } vm.stopPrank(); } function _executeSell(uint256 runIndex, uint256 tradeIndex) internal { uint256 kraikenBal = kraiken.balanceOf(trader); if (kraikenBal == 0) return; // 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; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), amount); try swapExecutor.executeSell(amount, trader) returns (uint256 actualAmount) { _recordState("SELL", actualAmount); } catch { _recordState("SELL_FAIL", amount); } vm.stopPrank(); } function _executeStakingOperation(uint256 runIndex, uint256 tradeIndex, uint256 stakingBias) internal { // Need KRAIKEN to stake — use the staker address, not the trader uint256 stakerKraiken = kraiken.balanceOf(staker); // If staker has no KRAIKEN and trader has some, transfer a small amount if (stakerKraiken == 0) { uint256 traderBal = kraiken.balanceOf(trader); if (traderBal == 0) return; uint256 toTransfer = traderBal / 10; // 10% of trader holdings if (toTransfer == 0) return; vm.prank(trader); kraiken.transfer(staker, toTransfer); stakerKraiken = toTransfer; } uint256 rand = uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "staking"))) % 100; if (rand < stakingBias && activePositionIds.length == 0) { // Stake: pick a tax rate (0-15 range, modest) uint32 taxRate = uint32(uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "taxrate"))) % 16); uint256 stakeAmount = stakerKraiken / 2; // Check minStake try kraiken.minStake() returns (uint256 minStake) { if (stakeAmount < minStake) { if (stakerKraiken >= minStake) { stakeAmount = minStake; } else { return; } } } catch { return; } totalStakesAttempted++; vm.startPrank(staker); kraiken.approve(address(stake), stakeAmount); uint256[] memory empty = new uint256[](0); try stake.snatch(stakeAmount, staker, taxRate, empty) returns (uint256 positionId) { activePositionIds.push(positionId); totalStakesSucceeded++; } catch { } vm.stopPrank(); } else if (activePositionIds.length > 0) { // Exit a random position uint256 idx = uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "exit"))) % activePositionIds.length; uint256 posId = activePositionIds[idx]; vm.prank(staker); try stake.exitPosition(posId) { // Remove from tracking activePositionIds[idx] = activePositionIds[activePositionIds.length - 1]; activePositionIds.pop(); } catch { } } } function _tryRecenter() internal { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter{ gas: 50_000_000 }() { lastRecenterBlock = block.number; _recordState("RECENTER", 0); } catch { } } function _liquidateTraderHoldings() internal { uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prevRemaining = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); try swapExecutor.executeSell(remaining, trader) returns (uint256 actualAmount) { if (actualAmount == 0) { vm.stopPrank(); break; } } catch { vm.stopPrank(); break; } vm.stopPrank(); // Recenter between liquidation attempts to unlock more liquidity if (attempts % 3 == 2) { _tryRecenter(); } remaining = kraiken.balanceOf(trader); if (remaining >= prevRemaining) break; unchecked { attempts++; } } } function _recordState(string memory action, uint256 amount) internal { string memory row = _buildRowPart1(action, amount); row = string(abi.encodePacked(row, _buildRowPart2())); row = string(abi.encodePacked(row, _buildRowPart3())); vm.writeLine(csvFilename, row); } function _buildRowPart1(string memory action, uint256 amount) internal view returns (string memory) { (, int24 tick,,,,,) = pool.slot0(); (uint128 floorLiq, int24 floorLower, int24 floorUpper) = lm.positions(ThreePositionStrategy.Stage.FLOOR); return string( abi.encodePacked( action, ",", vm.toString(amount), ",", vm.toString(tick), ",", vm.toString(floorLower), ",", vm.toString(floorUpper), ",", vm.toString(uint256(floorLiq)), "," ) ); } function _buildRowPart2() internal view returns (string memory) { (uint128 anchorLiq, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR); (uint128 discoveryLiq, int24 discoveryLower, int24 discoveryUpper) = lm.positions(ThreePositionStrategy.Stage.DISCOVERY); return string( abi.encodePacked( vm.toString(anchorLower), ",", vm.toString(anchorUpper), ",", vm.toString(uint256(anchorLiq)), ",", vm.toString(discoveryLower), ",", vm.toString(discoveryUpper), ",", vm.toString(uint256(discoveryLiq)), "," ) ); } function _buildRowPart3() internal view returns (string memory) { uint256 ethBalance = weth.balanceOf(trader); uint256 kraikenBalance = kraiken.balanceOf(trader); (uint128 fees0, uint128 fees1) = pool.protocolFees(); uint256 deltaFees0 = fees0 > totalFees0 ? fees0 - totalFees0 : 0; uint256 deltaFees1 = fees1 > totalFees1 ? fees1 - totalFees1 : 0; return string( abi.encodePacked( vm.toString(ethBalance), ",", vm.toString(kraikenBalance), ",", "0,", vm.toString(deltaFees0), ",", vm.toString(deltaFees1), ",", "0" ) ); } function _generateScenarioId() internal view returns (string memory) { uint256 rand = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))); bytes memory chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; bytes memory result = new bytes(4); for (uint256 i = 0; i < 4; i++) { result[i] = chars[rand % chars.length]; rand = rand / chars.length; } return string(result); } function _padNumber(uint256 num, uint256 digits) internal pure returns (string memory) { string memory numStr = vm.toString(num); bytes memory numBytes = bytes(numStr); if (numBytes.length >= digits) { return numStr; } bytes memory result = new bytes(digits); uint256 padding = digits - numBytes.length; for (uint256 i = 0; i < padding; i++) { result[i] = "0"; } for (uint256 i = 0; i < numBytes.length; i++) { result[padding + i] = numBytes[i]; } return string(result); } }