harb/onchain/analysis/helpers/FuzzingBase.sol

434 lines
20 KiB
Solidity
Raw Permalink 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 { BackgroundLP } from "./BackgroundLP.sol";
import { SwapExecutor } from "./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 FuzzingBase
/// @notice Shared infrastructure for all fuzzing/analysis scripts.
/// @dev Provides environment setup, trade execution, liquidation, token recovery,
/// and CSV parsing. Concrete scripts inherit this and implement their own run() logic.
abstract contract FuzzingBase is Script {
// ── Constants ──────────────────────────────────────────────────────────
uint256 internal constant LM_FUNDING_ETH = 200 ether;
uint256 internal constant LM_INITIAL_WETH = 100 ether;
uint256 internal constant RECENTER_GAS_LIMIT = 50_000_000;
uint256 internal constant RECENTER_TIME_ADVANCE = 1 hours;
uint256 internal constant LIQUIDATION_MAX_ATTEMPTS = 20;
uint256 internal constant LIQUIDATION_RECENTER_INTERVAL = 3;
address internal constant DEAD_ADDRESS = address(0xdead);
uint256 internal constant SELL_PCT_MIN = 20;
uint256 internal constant SELL_PCT_RANGE = 60;
// ── Environment ───────────────────────────────────────────────────────
TestEnvironment internal testEnv;
IUniswapV3Factory internal factory;
IUniswapV3Pool internal pool;
IWETH9 internal weth;
Kraiken internal kraiken;
Stake internal stake;
LiquidityManager internal lm;
SwapExecutor internal swapExecutor;
bool internal token0isWeth;
// ── Actors ────────────────────────────────────────────────────────────
address internal trader = makeAddr("trader");
address internal fees = makeAddr("fees");
BackgroundLP internal backgroundLP;
// ══════════════════════════════════════════════════════════════════════
// Environment Setup
// ══════════════════════════════════════════════════════════════════════
/// @notice Deploy TestEnvironment and Uniswap factory (call once per script)
function _initInfrastructure() internal {
testEnv = new TestEnvironment(fees);
factory = UniswapHelpers.deployUniswapFactory();
}
/// @notice Deploy a fresh pool/LM/optimizer environment
/// @param optimizer Address of the optimizer contract to use
/// @param wethIsToken0 Token ordering for the pool
/// @param uncapped Whether to deploy SwapExecutor in uncapped mode
function _setupEnvironment(address optimizer, bool wethIsToken0, bool uncapped) internal {
(factory, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, wethIsToken0, fees, optimizer);
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, uncapped);
vm.deal(address(lm), LM_FUNDING_ETH);
vm.prank(address(lm));
weth.deposit{ value: LM_INITIAL_WETH }();
vm.prank(fees);
try lm.recenter() { } catch { }
}
/// @notice Overload: setup with custom LM funding
function _setupEnvironment(address optimizer, bool wethIsToken0, bool uncapped, uint256 lmFundingEth) internal {
(factory, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, wethIsToken0, fees, optimizer);
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, uncapped);
vm.deal(address(lm), lmFundingEth);
vm.prank(address(lm));
weth.deposit{ value: lmFundingEth / 2 }();
vm.prank(fees);
try lm.recenter() { } catch { }
}
// ══════════════════════════════════════════════════════════════════════
// Background Liquidity (Gaussian competition)
// ══════════════════════════════════════════════════════════════════════
/// @notice Deploy background LP with Gaussian-approximating stacked positions.
/// KRK is acquired by buying from the pool (realistic — no free minting).
/// @param ethPerLayer ETH per layer (5 layers total). Half goes to buying KRK,
/// half stays as WETH for the liquidity positions.
function _deployBackgroundLP(uint256 ethPerLayer) internal {
backgroundLP = new BackgroundLP(pool, weth, kraiken, token0isWeth);
// Fund with ETH (2× total: half to buy KRK, half for LP positions)
uint256 totalEth = ethPerLayer * 5;
vm.deal(address(backgroundLP), totalEth * 4);
vm.prank(address(backgroundLP));
weth.deposit{ value: totalEth * 2 }();
// Buy KRK from the pool — just like a real LP would.
// Buy in small chunks with recenters to avoid overshooting thin anchors.
address bgAddr = address(backgroundLP);
uint256 chunkSize = totalEth / 5;
for (uint256 i = 0; i < 5; i++) {
uint256 buyAmount = chunkSize;
uint256 wethBal = weth.balanceOf(bgAddr);
if (buyAmount > wethBal / 2) buyAmount = wethBal / 2; // keep some WETH for LP
if (buyAmount == 0) break;
vm.startPrank(bgAddr);
weth.transfer(address(swapExecutor), buyAmount);
vm.stopPrank();
vm.prank(bgAddr);
try swapExecutor.executeBuy(buyAmount, bgAddr) { } catch { }
// Recenter between chunks to rebuild positions
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.prank(fees);
try lm.recenter() { } catch { }
}
backgroundLP.rebalance(ethPerLayer);
}
/// @notice Rebalance background LP positions to track current tick.
/// Uses existing token balances — no new ETH or KRK injected.
function _rebalanceBackgroundLP(uint256 ethPerLayer) internal {
if (address(backgroundLP) == address(0)) return;
backgroundLP.rebalance(ethPerLayer);
}
/// @notice Get fee revenue collected by feeDestination
/// @return feeWeth WETH fees, feeKrk KRK fees
function _getFeeRevenue() internal view returns (uint256 feeWeth, uint256 feeKrk) {
feeWeth = weth.balanceOf(fees);
feeKrk = kraiken.balanceOf(fees);
}
// ══════════════════════════════════════════════════════════════════════
// Staking Setup
// ══════════════════════════════════════════════════════════════════════
/// @notice Pre-seed staking to simulate a given sentiment level.
/// Buys KRK from the pool and stakes it at the given tax rate.
/// @param stakingPct Target % of authorized stake to fill (0-100, in whole %)
/// @param taxRateIdx Tax rate index (0-29, lower = cheaper = more bullish)
function _seedStaking(uint256 stakingPct, uint32 taxRateIdx) internal {
if (stakingPct == 0) return;
address staker = makeAddr("staker");
// Buy KRK for staking — use 50 ETH per attempt, loop until target met
uint256 maxAttempts = 20;
for (uint256 attempt = 0; attempt < maxAttempts; attempt++) {
// Check current staking level
uint256 currentPct = stake.getPercentageStaked() * 100 / 1e18;
if (currentPct >= stakingPct) break;
// Fund staker with ETH
vm.deal(staker, 100 ether);
vm.prank(staker);
weth.deposit{ value: 50 ether }();
// Buy KRK
vm.startPrank(staker);
weth.transfer(address(swapExecutor), 50 ether);
vm.stopPrank();
vm.prank(staker);
try swapExecutor.executeBuy(50 ether, staker) { }
catch {
break;
}
// Recenter to rebuild positions
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.prank(fees);
try lm.recenter() { } catch { }
// Stake all KRK
uint256 krkBal = kraiken.balanceOf(staker);
if (krkBal == 0) break;
vm.startPrank(staker);
kraiken.approve(address(stake), krkBal);
uint256[] memory empty = new uint256[](0);
try stake.snatch(krkBal, staker, taxRateIdx, empty) { }
catch {
vm.stopPrank();
break;
}
vm.stopPrank();
}
// Final recenter to pick up new optimizer params
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.prank(fees);
try lm.recenter() { } catch { }
}
// ══════════════════════════════════════════════════════════════════════
// Trade Execution
// ══════════════════════════════════════════════════════════════════════
/// @notice Execute a buy trade via the SwapExecutor
/// @return success Whether the buy produced a non-zero result
function _executeBuy(uint256 amount) internal returns (bool success) {
if (amount == 0 || weth.balanceOf(trader) < amount) return false;
vm.startPrank(trader);
weth.transfer(address(swapExecutor), amount);
try swapExecutor.executeBuy(amount, trader) returns (uint256 actualAmount) {
vm.stopPrank();
_recoverStuckTokens();
return actualAmount > 0;
} catch {
vm.stopPrank();
_recoverStuckTokens();
return false;
}
}
/// @notice Execute a sell trade via the SwapExecutor
/// @return success Whether the sell produced a non-zero result
function _executeSell(uint256 amount) internal returns (bool success) {
if (amount == 0 || kraiken.balanceOf(trader) < amount) return false;
vm.startPrank(trader);
kraiken.transfer(address(swapExecutor), amount);
try swapExecutor.executeSell(amount, trader) returns (uint256 actualAmount) {
vm.stopPrank();
_recoverStuckTokens();
return actualAmount > 0;
} catch {
vm.stopPrank();
_recoverStuckTokens();
return false;
}
}
// ══════════════════════════════════════════════════════════════════════
// Recenter
// ══════════════════════════════════════════════════════════════════════
/// @notice Advance time and attempt a recenter
function _tryRecenter() internal returns (bool) {
vm.warp(block.timestamp + RECENTER_TIME_ADVANCE);
vm.roll(block.number + 1);
vm.prank(fees);
try lm.recenter{ gas: RECENTER_GAS_LIMIT }() {
return true;
} catch {
return false;
}
}
// ══════════════════════════════════════════════════════════════════════
// Liquidation
// ══════════════════════════════════════════════════════════════════════
/// @notice Sell all trader's KRAIKEN, retrying with recenters between attempts
function _liquidateTraderHoldings() internal {
uint256 remaining = kraiken.balanceOf(trader);
uint256 attempts;
while (remaining > 0 && attempts < LIQUIDATION_MAX_ATTEMPTS) {
uint256 prev = remaining;
uint256 wethBefore = weth.balanceOf(trader);
vm.startPrank(trader);
kraiken.transfer(address(swapExecutor), remaining);
vm.stopPrank();
try swapExecutor.executeSell(remaining, trader) {
if (weth.balanceOf(trader) <= wethBefore) {
_recoverStuckTokens();
break;
}
} catch {
_recoverStuckTokens();
break;
}
_recoverStuckTokens();
if (attempts % LIQUIDATION_RECENTER_INTERVAL == LIQUIDATION_RECENTER_INTERVAL - 1) {
_tryRecenter();
}
remaining = kraiken.balanceOf(trader);
if (remaining >= prev) break;
unchecked {
attempts++;
}
}
_recoverStuckTokens();
}
// ══════════════════════════════════════════════════════════════════════
// Token Recovery & Cleanup
// ══════════════════════════════════════════════════════════════════════
/// @notice Recover any tokens stuck in SwapExecutor back to the trader
function _recoverStuckTokens() internal {
uint256 stuckKraiken = kraiken.balanceOf(address(swapExecutor));
if (stuckKraiken > 0) {
vm.prank(address(swapExecutor));
kraiken.transfer(trader, stuckKraiken);
}
uint256 stuckWeth = weth.balanceOf(address(swapExecutor));
if (stuckWeth > 0) {
vm.prank(address(swapExecutor));
weth.transfer(trader, stuckWeth);
}
}
/// @notice Burn all trader + SwapExecutor tokens to prevent PnL leakage between runs
function _cleanupTraderTokens() internal {
uint256 leftoverWeth = weth.balanceOf(trader);
if (leftoverWeth > 0) {
vm.prank(trader);
weth.transfer(DEAD_ADDRESS, leftoverWeth);
}
uint256 leftoverKraiken = kraiken.balanceOf(trader);
if (leftoverKraiken > 0) {
vm.prank(trader);
kraiken.transfer(DEAD_ADDRESS, leftoverKraiken);
}
uint256 stuckWeth = weth.balanceOf(address(swapExecutor));
if (stuckWeth > 0) {
vm.prank(address(swapExecutor));
weth.transfer(DEAD_ADDRESS, stuckWeth);
}
uint256 stuckKraiken = kraiken.balanceOf(address(swapExecutor));
if (stuckKraiken > 0) {
vm.prank(address(swapExecutor));
kraiken.transfer(DEAD_ADDRESS, stuckKraiken);
}
}
// ══════════════════════════════════════════════════════════════════════
// LM ETH Measurement
// ══════════════════════════════════════════════════════════════════════
/// @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));
}
// ══════════════════════════════════════════════════════════════════════
// CSV Parsing Utilities
// ══════════════════════════════════════════════════════════════════════
/// @notice Parse a comma-separated string of uint256 values
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;
uint256 start;
for (uint256 i = 0; i <= b.length; i++) {
if (i == b.length || b[i] == ",") {
uint256 num;
for (uint256 j = start; j < i; j++) {
require(b[j] >= "0" && b[j] <= "9", "Invalid number in CSV");
num = num * 10 + (uint256(uint8(b[j])) - 48);
}
result[idx] = num;
idx++;
start = i + 1;
}
}
return result;
}
// ══════════════════════════════════════════════════════════════════════
// String Helpers
// ══════════════════════════════════════════════════════════════════════
/// @notice Generate a random 4-character alphanumeric ID
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);
}
/// @notice Pad a number with leading zeros
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);
}
}