harb/onchain/analysis/StreamlinedFuzzing.s.sol

471 lines
18 KiB
Solidity
Raw Normal View History

2025-08-23 22:32:41 +02:00
// 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";
2025-08-23 22:32:41 +02:00
import "forge-std/Script.sol";
2025-09-16 22:46:43 +02:00
import "forge-std/console2.sol";
2025-08-23 22:32:41 +02:00
contract StreamlinedFuzzing is Script {
// Test environment
TestEnvironment testEnv;
IUniswapV3Factory factory;
IUniswapV3Pool pool;
IWETH9 weth;
Kraiken kraiken;
Stake stake;
LiquidityManager lm;
SwapExecutor swapExecutor;
bool token0isWeth;
2025-08-23 22:32:41 +02:00
// Actors
address trader = makeAddr("trader");
address staker = makeAddr("staker");
2025-08-23 22:32:41 +02:00
address fees = makeAddr("fees");
2025-08-23 22:32:41 +02:00
// Staking tracking
uint256[] public activePositionIds;
2025-08-23 22:32:41 +02:00
uint256 totalStakesAttempted;
uint256 totalStakesSucceeded;
2025-08-23 22:32:41 +02:00
// CSV filename for current run
string csvFilename;
2025-08-23 22:32:41 +02:00
// Track cumulative fees
uint256 totalFees0;
uint256 totalFees1;
2025-08-23 22:32:41 +02:00
// Track recentering
uint256 lastRecenterBlock;
// Config
uint256 cfgBuyBias;
uint256 cfgMinBuy;
uint256 cfgMaxBuy;
bool cfgUncapped;
2025-08-23 22:32:41 +02:00
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));
2025-08-23 22:32:41 +02:00
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));
2025-09-16 22:46:43 +02:00
console2.log("=== Streamlined Fuzzing Analysis ===");
console2.log("Optimizer:", optimizerClass);
console2.log("Uncapped swaps:", cfgUncapped);
console2.log("Trade range (ETH):", cfgMinBuy, cfgMaxBuy);
// Deploy factory once
2025-08-23 22:32:41 +02:00
testEnv = new TestEnvironment(fees);
factory = UniswapHelpers.deployUniswapFactory();
2025-08-23 22:32:41 +02:00
// 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
2025-08-23 22:32:41 +02:00
for (uint256 runIndex = 0; runIndex < numRuns; runIndex++) {
string memory runId = string(abi.encodePacked(scenarioCode, "-", _padNumber(runIndex, 3)));
2025-09-16 22:46:43 +02:00
console2.log("\nRun:", runId);
2025-08-23 22:32:41 +02:00
// 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";
2025-08-23 22:32:41 +02:00
vm.writeFile(csvFilename, header);
// Reset tracking for CSV
2025-08-23 22:32:41 +02:00
totalFees0 = 0;
totalFees1 = 0;
lastRecenterBlock = block.number;
2025-09-16 22:46:43 +02:00
// 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);
2025-08-23 22:32:41 +02:00
vm.prank(trader);
weth.deposit{ value: traderFund }();
2025-08-23 22:32:41 +02:00
// Initial state
_recordState("INIT", 0);
2025-08-23 22:32:41 +02:00
// Execute trades
for (uint256 i = 0; i < tradesPerRun; i++) {
2025-08-24 18:38:48 +02:00
// Check for recenter opportunity on average every 3 trades
uint256 recenterRand = uint256(keccak256(abi.encodePacked(runIndex, i, "recenter"))) % 3;
if (recenterRand == 0) {
2025-08-23 22:32:41 +02:00
_tryRecenter();
}
2025-08-23 22:32:41 +02:00
// Determine trade based on bias
uint256 rand = uint256(keccak256(abi.encodePacked(runIndex, i))) % 100;
if (rand < cfgBuyBias) {
2025-08-23 22:32:41 +02:00
_executeBuy(runIndex, i);
} else {
_executeSell(runIndex, i);
}
2025-08-23 22:32:41 +02:00
// Staking operations if enabled
if (enableStaking && i % 5 == 0) {
2025-08-23 22:32:41 +02:00
_executeStakingOperation(runIndex, i, stakingBias);
}
}
2025-09-16 22:46:43 +02:00
// Final recenter + liquidate
_tryRecenter();
2025-09-16 22:46:43 +02:00
_liquidateTraderHoldings();
2025-08-23 22:32:41 +02:00
_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));
2025-08-23 22:32:41 +02:00
}
2025-09-16 22:46:43 +02:00
uint256 lmEthEnd = address(lm).balance + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool));
2025-09-16 22:46:43 +02:00
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");
}
2025-09-16 22:46:43 +02:00
console2.log("Generated", numRuns, "CSV files with prefix:", scenarioCode);
2025-08-23 22:32:41 +02:00
}
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"));
}
}
2025-08-23 22:32:41 +02:00
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);
2025-08-23 22:32:41 +02:00
// Fund liquidity manager
vm.deal(address(lm), 200 ether);
vm.prank(address(lm));
weth.deposit{ value: 100 ether }();
// Initial recenter to set positions
2025-08-23 22:32:41 +02:00
vm.prank(fees);
try lm.recenter() returns (bool isUp) {
2025-09-16 22:46:43 +02:00
console2.log("Initial recenter successful, isUp:", isUp);
2025-08-23 22:32:41 +02:00
} catch Error(string memory reason) {
2025-09-16 22:46:43 +02:00
console2.log("Initial recenter failed:", reason);
2025-08-23 22:32:41 +02:00
} catch {
2025-09-16 22:46:43 +02:00
console2.log("Initial recenter failed with unknown error");
2025-08-23 22:32:41 +02:00
}
}
2025-08-23 22:32:41 +02:00
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());
}
}
2025-08-23 22:32:41 +02:00
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));
2025-08-23 22:32:41 +02:00
if (amount == 0 || weth.balanceOf(trader) < amount) return;
2025-08-23 22:32:41 +02:00
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);
2025-08-23 22:32:41 +02:00
}
_recordState("BUY", actualAmount);
} catch {
2025-08-23 22:32:41 +02:00
_recordState("BUY_FAIL", amount);
}
vm.stopPrank();
}
2025-08-23 22:32:41 +02:00
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;
2025-08-23 22:32:41 +02:00
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 { }
}
2025-08-23 22:32:41 +02:00
}
2025-08-23 22:32:41 +02:00
function _tryRecenter() internal {
2025-08-24 18:38:48 +02:00
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
2025-08-24 18:38:48 +02:00
vm.prank(fees);
try lm.recenter{ gas: 50_000_000 }() {
2025-08-24 18:38:48 +02:00
lastRecenterBlock = block.number;
_recordState("RECENTER", 0);
} catch { }
2025-08-23 22:32:41 +02:00
}
2025-09-16 22:46:43 +02:00
function _liquidateTraderHoldings() internal {
uint256 remaining = kraiken.balanceOf(trader);
uint256 attempts;
while (remaining > 0 && attempts < 20) {
2025-09-16 22:46:43 +02:00
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();
2025-09-16 22:46:43 +02:00
}
remaining = kraiken.balanceOf(trader);
if (remaining >= prevRemaining) break;
2025-09-16 22:46:43 +02:00
unchecked {
attempts++;
}
}
}
2025-08-23 22:32:41 +02:00
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()));
2025-08-23 22:32:41 +02:00
vm.writeLine(csvFilename, row);
}
2025-08-23 22:32:41 +02:00
function _buildRowPart1(string memory action, uint256 amount) internal view returns (string memory) {
(, int24 tick,,,,,) = pool.slot0();
2025-08-23 22:32:41 +02:00
(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)),
","
)
);
2025-08-23 22:32:41 +02:00
}
2025-08-23 22:32:41 +02:00
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)),
","
)
);
2025-08-23 22:32:41 +02:00
}
2025-08-23 22:32:41 +02:00
function _buildRowPart3() internal view returns (string memory) {
uint256 ethBalance = weth.balanceOf(trader);
uint256 kraikenBalance = kraiken.balanceOf(trader);
2025-08-23 22:32:41 +02:00
(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"
)
);
2025-08-23 22:32:41 +02:00
}
2025-08-23 22:32:41 +02:00
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);
2025-08-23 22:32:41 +02:00
for (uint256 i = 0; i < 4; i++) {
result[i] = chars[rand % chars.length];
rand = rand / chars.length;
}
2025-08-23 22:32:41 +02:00
return string(result);
}
2025-08-23 22:32:41 +02:00
function _padNumber(uint256 num, uint256 digits) internal pure returns (string memory) {
string memory numStr = vm.toString(num);
bytes memory numBytes = bytes(numStr);
2025-08-23 22:32:41 +02:00
if (numBytes.length >= digits) {
return numStr;
}
2025-08-23 22:32:41 +02:00
bytes memory result = new bytes(digits);
uint256 padding = digits - numBytes.length;
2025-08-23 22:32:41 +02:00
for (uint256 i = 0; i < padding; i++) {
result[i] = "0";
2025-08-23 22:32:41 +02:00
}
2025-08-23 22:32:41 +02:00
for (uint256 i = 0; i < numBytes.length; i++) {
result[padding + i] = numBytes[i];
}
2025-08-23 22:32:41 +02:00
return string(result);
}
2025-09-16 22:46:43 +02:00
}