382 lines
15 KiB
Solidity
382 lines
15 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.19;
|
|
|
|
import "forge-std/Script.sol";
|
|
import "forge-std/console2.sol";
|
|
import {TestEnvironment} from "../test/helpers/TestBase.sol";
|
|
import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
|
|
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
import {UniswapHelpers} from "../src/helpers/UniswapHelpers.sol";
|
|
import {IWETH9} from "../src/interfaces/IWETH9.sol";
|
|
import {Kraiken} from "../src/Kraiken.sol";
|
|
import {Stake} from "../src/Stake.sol";
|
|
import {LiquidityManager} from "../src/LiquidityManager.sol";
|
|
import {ThreePositionStrategy} from "../src/abstracts/ThreePositionStrategy.sol";
|
|
import {SwapExecutor} from "./helpers/SwapExecutor.sol";
|
|
import {Optimizer} from "../src/Optimizer.sol";
|
|
import {BullMarketOptimizer} from "../test/mocks/BullMarketOptimizer.sol";
|
|
import {BearMarketOptimizer} from "../test/mocks/BearMarketOptimizer.sol";
|
|
import {NeutralMarketOptimizer} from "../test/mocks/NeutralMarketOptimizer.sol";
|
|
import {WhaleOptimizer} from "../test/mocks/WhaleOptimizer.sol";
|
|
import {ExtremeOptimizer} from "../test/mocks/ExtremeOptimizer.sol";
|
|
import {MaliciousOptimizer} from "../test/mocks/MaliciousOptimizer.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 fees = makeAddr("fees");
|
|
|
|
// Staking tracking
|
|
mapping(address => uint256[]) public activePositions;
|
|
uint256[] public allPositionIds;
|
|
uint256 totalStakesAttempted;
|
|
uint256 totalStakesSucceeded;
|
|
uint256 totalSnatchesAttempted;
|
|
uint256 totalSnatchesSucceeded;
|
|
|
|
// CSV filename for current run
|
|
string csvFilename;
|
|
|
|
// Track cumulative fees
|
|
uint256 totalFees0;
|
|
uint256 totalFees1;
|
|
|
|
// Track recentering
|
|
uint256 lastRecenterBlock;
|
|
|
|
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);
|
|
uint256 buyBias = vm.envOr("BUY_BIAS", uint256(50));
|
|
uint256 stakingBias = vm.envOr("STAKING_BIAS", uint256(80));
|
|
string memory optimizerClass = vm.envOr("OPTIMIZER_CLASS", string("BullMarketOptimizer"));
|
|
|
|
console2.log("=== Streamlined Fuzzing Analysis ===");
|
|
console2.log("Optimizer:", optimizerClass);
|
|
|
|
// Deploy factory once for all runs (gas optimization)
|
|
testEnv = new TestEnvironment(fees);
|
|
factory = UniswapHelpers.deployUniswapFactory();
|
|
|
|
// Generate unique 4-character scenario ID
|
|
string memory scenarioCode = _generateScenarioId();
|
|
|
|
// Run fuzzing scenarios
|
|
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
|
|
// Always write to analysis directory relative to project root
|
|
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);
|
|
|
|
// Setup fresh environment for each run
|
|
_setupEnvironment(optimizerClass, runIndex % 2 == 0);
|
|
|
|
// Reset tracking variables
|
|
totalFees0 = 0;
|
|
totalFees1 = 0;
|
|
lastRecenterBlock = block.number;
|
|
|
|
// Fund trader based on run seed - increased for longer campaigns
|
|
uint256 traderFund = 500 ether + (uint256(keccak256(abi.encodePacked(runIndex, "trader"))) % 500 ether);
|
|
|
|
vm.deal(trader, traderFund * 2);
|
|
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 < buyBias) {
|
|
_executeBuy(runIndex, i);
|
|
} else {
|
|
_executeSell(runIndex, i);
|
|
}
|
|
|
|
// Staking operations if enabled
|
|
if (enableStaking && i % 3 == 0) {
|
|
_executeStakingOperation(runIndex, i, stakingBias);
|
|
}
|
|
}
|
|
|
|
// Final state
|
|
_liquidateTraderHoldings();
|
|
_recordState("FINAL", 0);
|
|
}
|
|
|
|
console2.log("\n=== Analysis Complete ===");
|
|
console2.log("Generated", numRuns, "CSV files with prefix:", scenarioCode);
|
|
}
|
|
|
|
function _setupEnvironment(string memory optimizerClass, bool wethIsToken0) internal {
|
|
// Get optimizer address
|
|
address optimizer = _deployOptimizer(optimizerClass);
|
|
|
|
// Setup new environment
|
|
(factory, pool, weth, kraiken, stake, lm,, token0isWeth) =
|
|
testEnv.setupEnvironmentWithExistingFactory(factory, wethIsToken0, fees, optimizer);
|
|
|
|
// Deploy swap executor with liquidity boundary checks
|
|
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm);
|
|
|
|
// Fund liquidity manager
|
|
vm.deal(address(lm), 200 ether);
|
|
|
|
// Initialize liquidity positions
|
|
// First need to give LM some WETH
|
|
vm.prank(address(lm));
|
|
weth.deposit{value: 100 ether}();
|
|
|
|
// Now try recenter from fee destination
|
|
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");
|
|
}
|
|
|
|
// Clear staking state
|
|
delete allPositionIds;
|
|
totalStakesAttempted = 0;
|
|
totalStakesSucceeded = 0;
|
|
totalSnatchesAttempted = 0;
|
|
totalSnatchesSucceeded = 0;
|
|
}
|
|
|
|
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 {
|
|
// Default to bull market
|
|
return address(new BullMarketOptimizer());
|
|
}
|
|
}
|
|
|
|
function _executeBuy(uint256 runIndex, uint256 tradeIndex) internal {
|
|
uint256 amount = _getTradeAmount(runIndex, tradeIndex, true);
|
|
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);
|
|
}
|
|
_recordState("BUY", actualAmount);
|
|
} catch Error(string memory reason) {
|
|
console2.log("Buy failed:", reason);
|
|
_recordState("BUY_FAIL", amount);
|
|
}
|
|
vm.stopPrank();
|
|
}
|
|
|
|
function _executeSell(uint256 runIndex, uint256 tradeIndex) internal {
|
|
uint256 amount = _getTradeAmount(runIndex, tradeIndex, false);
|
|
if (amount == 0 || kraiken.balanceOf(trader) < amount) 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, uint256, uint256) internal {
|
|
// Staking operations disabled for now - interface needs updating
|
|
// TODO: Update to use correct Stake contract interface
|
|
}
|
|
|
|
function _tryRecenter() internal {
|
|
vm.warp(block.timestamp + 1 hours);
|
|
vm.roll(block.number + 1); // Advance block
|
|
vm.prank(fees);
|
|
try lm.recenter{gas: 50_000_000}() {
|
|
lastRecenterBlock = block.number;
|
|
_recordState("RECENTER", 0);
|
|
} catch {}
|
|
}
|
|
|
|
function _getTradeAmount(uint256 runIndex, uint256 tradeIndex, bool isBuy) internal pure returns (uint256) {
|
|
uint256 baseAmount = 10 ether + (uint256(keccak256(abi.encodePacked(runIndex, tradeIndex))) % 90 ether);
|
|
return isBuy ? baseAmount : baseAmount * 1000;
|
|
}
|
|
|
|
function _liquidateTraderHoldings() internal {
|
|
uint256 remaining = kraiken.balanceOf(trader);
|
|
uint256 attempts;
|
|
|
|
// Repeatedly sell down inventory, respecting liquidity limits in SwapExecutor
|
|
while (remaining > 0 && attempts < 10) {
|
|
uint256 prevRemaining = remaining;
|
|
|
|
vm.startPrank(trader);
|
|
kraiken.transfer(address(swapExecutor), remaining);
|
|
try swapExecutor.executeSell(remaining, trader) returns (uint256 actualAmount) {
|
|
if (actualAmount == 0) {
|
|
vm.stopPrank();
|
|
console2.log("Liquidity liquidation halted: sell returned 0");
|
|
break;
|
|
}
|
|
} catch Error(string memory reason) {
|
|
vm.stopPrank();
|
|
console2.log("Liquidity liquidation failed:", reason);
|
|
break;
|
|
} catch {
|
|
vm.stopPrank();
|
|
console2.log("Liquidity liquidation failed with unknown error");
|
|
break;
|
|
}
|
|
vm.stopPrank();
|
|
|
|
remaining = kraiken.balanceOf(trader);
|
|
if (remaining >= prevRemaining) {
|
|
console2.log("Liquidity liquidation made no progress; remaining KRAIKEN:", remaining);
|
|
break;
|
|
}
|
|
|
|
unchecked {
|
|
attempts++;
|
|
}
|
|
}
|
|
|
|
if (kraiken.balanceOf(trader) > 0) {
|
|
console2.log("Warning: trader still holds KRAIKEN after liquidation:", kraiken.balanceOf(trader));
|
|
}
|
|
}
|
|
|
|
function _recordState(string memory action, uint256 amount) internal {
|
|
// Build CSV row in parts to avoid stack too deep
|
|
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();
|
|
|
|
// Get floor position
|
|
(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) {
|
|
// Get anchor and discovery positions
|
|
(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) {
|
|
// Get balances and fees
|
|
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,", // vwap placeholder
|
|
vm.toString(deltaFees0), ",",
|
|
vm.toString(deltaFees1), ",",
|
|
"0" // recenter flag placeholder - no newline here
|
|
));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|