harb/onchain/analysis/ParameterSweepFuzzing.s.sol

307 lines
12 KiB
Solidity
Raw Normal View History

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