harb/onchain/script/backtesting/AttackRunner.s.sol

819 lines
34 KiB
Solidity
Raw Normal View History

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { FormatLib } from "./FormatLib.sol";
import { FullMath } from "@aperture/uni-v3-lib/FullMath.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { ABDKMath64x64 } from "@abdk/ABDKMath64x64.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
// ─── Minimal external interfaces ────────────────────────────────────────────
interface IWETH9 {
function deposit() external payable;
}
interface ISwapRouter02 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256 amountOut);
}
interface ILM {
function getVWAP() external view returns (uint256);
function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper);
function recenter() external returns (bool);
}
interface IKraiken is IERC20 {
function outstandingSupply() external view returns (uint256);
}
interface IOptimizer {
function getLiquidityParams()
external
view
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth);
}
interface IStake {
// taxRate matches the actual Stake.sol parameter name (a raw rate value, not a lookup index)
function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch) external returns (uint256 positionId);
function exitPosition(uint256 positionId) external;
}
interface INonfungiblePositionManager {
struct MintParams {
address token0;
address token1;
uint24 fee;
int24 tickLower;
int24 tickUpper;
uint256 amount0Desired;
uint256 amount1Desired;
uint256 amount0Min;
uint256 amount1Min;
address recipient;
uint256 deadline;
}
struct DecreaseLiquidityParams {
uint256 tokenId;
uint128 liquidity;
uint256 amount0Min;
uint256 amount1Min;
uint256 deadline;
}
struct CollectParams {
uint256 tokenId;
address recipient;
uint128 amount0Max;
uint128 amount1Max;
}
function mint(MintParams calldata params) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
function positions(uint256 tokenId)
external
view
returns (
uint96 nonce,
address operator,
address token0,
address token1,
uint24 fee,
int24 tickLower,
int24 tickUpper,
uint128 liquidity,
uint256 feeGrowthInside0LastX128,
uint256 feeGrowthInside1LastX128,
uint128 tokensOwed0,
uint128 tokensOwed1
);
function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1);
}
interface IUniswapV3Factory {
function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address pool);
}
/**
* @title AttackRunner
* @notice Structured attack executor for replaying adversarial sequences against the LM.
*
* Reads operations from a JSON Lines file (one op per line) specified by the ATTACK_FILE
* environment variable. Executes each operation against the local Anvil deployment and emits
* a state snapshot after every recenter and at the start and end of the sequence.
*
* Usage:
* ATTACK_FILE=script/backtesting/attacks/il-crystallization-15.jsonl \
* forge script script/backtesting/AttackRunner.s.sol \
* --rpc-url http://localhost:8545 --broadcast
*
* Optional overrides:
* DEPLOYMENTS_FILE Path to deployments JSON (default: deployments-local.json)
*
* Supported operations:
* buy Swap WETHKRK via SwapRouter. Fields: amount (wei string), token (ignored, WETH assumed)
* sell Swap KRKWETH via SwapRouter. Fields: amount (wei string or "all"), token (ignored)
* recenter Call LM.recenter() via recenterAccess account. Emits a snapshot.
* buy_recenter_loop Batch N×(buyrecenter) cycles in a single op. Fields: count (uint), amount (wei string).
* Emits a snapshot after each successful recenter. Avoids per-step forge overhead for
* high-cycle attacks that would otherwise time out (e.g. il-crystallization-80).
* stake Call Stake.snatch(). Fields: amount (wei string), taxRateIndex (raw taxRate value passed to Stake.snatch)
* unstake Call Stake.exitPosition(). Fields: positionId
* mint_lp Add LP via NPM. Fields: tickLower, tickUpper, amount0 (wei string), amount1 (wei string)
* burn_lp Remove LP via NPM. Fields: positionIndex (1-based index of a prior mint_lp op)
* mine Advance block number. Fields: blocks
*
* Snapshot schema (emitted as JSON line on stdout):
* seq, tick, lm_eth_free, lm_weth_free, lm_eth_total, positions (floor/anchor/discovery),
* vwap_x96, vwap_tick, outstanding_supply, total_supply, optimizer_output, adversary_eth, adversary_krk
*/
contract AttackRunner is Script {
using FormatLib for uint256;
using FormatLib for int256;
// ─── Protocol constants (local Anvil deployment) ──────────────────────────
uint24 internal constant POOL_FEE = 10_000;
address internal constant WETH = 0x4200000000000000000000000000000000000006;
address internal constant DEFAULT_SWAP_ROUTER = 0x2626664c2603336E57B271c5C0b26F421741e481;
address internal constant DEFAULT_NPM_ADDR = 0x03a520B32c04bf3beef7BEb72E919cF822Ed34F3;
address internal constant DEFAULT_V3_FACTORY = 0x33128a8fC17869897dcE68Ed026d694621f6FDfD; // Base mainnet
// ─── Anvil test accounts ──────────────────────────────────────────────────
/// @dev Account 8 — adversary (10 000 ETH, 0 KRK)
uint256 internal constant ADV_PK = 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97;
/// @dev Account 2 — recenter caller (granted recenterAccess by bootstrap)
uint256 internal constant RECENTER_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a;
// ─── Runtime state (populated in run()) ──────────────────────────────────
address internal swapRouter;
address internal npmAddr;
address internal v3Factory;
address internal advAddr;
address internal recenterAddr;
address internal lmAddr;
address internal krkAddr;
address internal stakeAddr;
address internal optAddr;
IUniswapV3Pool internal pool;
bool internal token0isWeth;
/// @dev On-chain position IDs returned by Stake.snatch(), in insertion order.
/// Attack files reference positions by 1-based index (positionId=1 → _stakedPositionIds[0]).
uint256[] internal _stakedPositionIds;
/// @dev NFT token IDs returned by NPM.mint(), in insertion order.
/// Attack files reference LP positions by 1-based index (positionIndex=1 → _mintedLpTokenIds[0]).
/// This makes burn_lp fork-block-independent — the tokenId is resolved at runtime.
uint256[] internal _mintedLpTokenIds;
/// @dev Direction of the most recent recenter: true = price moved up, false = price moved down.
/// Read by _logSnapshot to include in post-recenter snapshots.
bool internal _lastRecenterIsUp;
/// @dev Set to true after the first recenter call. Allows _logSnapshot to emit
/// recenter_is_up=null on the initial snapshot (before any recenter has occurred)
/// rather than the ambiguous false default.
bool internal _hasRecentered;
/// @dev Snapshot sequence counter. Shared between run() and batch ops like
/// buy_recenter_loop that emit their own snapshots internally.
uint256 internal _seq;
// ─── Entry point ─────────────────────────────────────────────────────────
function run() external {
// Resolve periphery addresses from environment, falling back to mainnet defaults.
swapRouter = vm.envOr("SWAP_ROUTER", DEFAULT_SWAP_ROUTER);
npmAddr = vm.envOr("NPM_ADDR", DEFAULT_NPM_ADDR);
v3Factory = vm.envOr("V3_FACTORY", DEFAULT_V3_FACTORY);
// Load deployment addresses before broadcast.
string memory deploymentsPath = _deploymentsPath();
string memory deployJson = vm.readFile(deploymentsPath);
lmAddr = vm.parseJsonAddress(deployJson, ".contracts.LiquidityManager");
krkAddr = vm.parseJsonAddress(deployJson, ".contracts.Kraiken");
stakeAddr = vm.parseJsonAddress(deployJson, ".contracts.Stake");
optAddr = vm.parseJsonAddress(deployJson, ".contracts.OptimizerProxy");
advAddr = vm.addr(ADV_PK);
recenterAddr = vm.addr(RECENTER_PK);
// Derive pool address from factory.
pool = IUniswapV3Pool(IUniswapV3Factory(v3Factory).getPool(WETH, krkAddr, POOL_FEE));
require(address(pool) != address(0), "AttackRunner: pool not found");
token0isWeth = pool.token0() == WETH;
// Wrap ETH and set approvals for the adversary.
_setup();
// Initial state snapshot (seq=0).
_logSnapshot(0);
// Execute attack operations, snapshotting after each recenter.
string memory attackFile = vm.envString("ATTACK_FILE");
_seq = 1;
string memory line = vm.readLine(attackFile);
while (bytes(line).length > 0) {
// Skip comment lines (e.g. "// schema-version: 1" header).
if (bytes(line).length >= 2 && bytes(line)[0] == 0x2F && bytes(line)[1] == 0x2F) {
line = vm.readLine(attackFile);
continue;
}
bool isRecenter = _execute(line);
if (isRecenter) {
_logSnapshot(_seq++);
}
line = vm.readLine(attackFile);
}
// Final state snapshot.
_logSnapshot(_seq);
}
// ─── Setup ────────────────────────────────────────────────────────────────
/// @notice Wrap ETH and pre-approve all tokens for the adversary account.
function _setup() internal {
vm.startBroadcast(ADV_PK);
// Wrap most of the adversary's ETH (leave 1 ETH for gas).
// The adversary starts with 10 000 ETH; wrapping 9 000 covers the heaviest buy sequences.
IWETH9(WETH).deposit{ value: 9_000 ether }();
IERC20(WETH).approve(swapRouter, type(uint256).max);
IERC20(WETH).approve(npmAddr, type(uint256).max);
IERC20(krkAddr).approve(swapRouter, type(uint256).max);
IERC20(krkAddr).approve(stakeAddr, type(uint256).max);
IERC20(krkAddr).approve(npmAddr, type(uint256).max);
vm.stopBroadcast();
}
// ─── Operation dispatcher ─────────────────────────────────────────────────
/**
* @notice Execute a single operation line and return true if it was a recenter.
* @param line A single JSON object from the attack JSONL file.
*/
function _execute(string memory line) internal returns (bool isRecenter) {
string memory op = vm.parseJsonString(line, ".op");
if (_eq(op, "buy")) {
_executeBuy(line);
} else if (_eq(op, "sell")) {
_executeSell(line);
} else if (_eq(op, "recenter")) {
vm.startBroadcast(RECENTER_PK);
// recenter() reverts when amplitude is insufficient (price hasn't moved far enough
// from the anchor center). Use try/catch so attacks with round-trip buy→sell cycles
// — where the net price movement is near zero — don't abort the whole script.
// A caught revert means no state change occurred; skip the snapshot for this step.
try ILM(lmAddr).recenter() returns (bool isUp) {
_lastRecenterIsUp = isUp;
_hasRecentered = true;
isRecenter = true;
} catch {
console.log("recenter: skipped (amplitude not reached)");
}
vm.stopBroadcast();
} else if (_eq(op, "stake")) {
_executeStake(line);
} else if (_eq(op, "unstake")) {
_executeUnstake(line);
} else if (_eq(op, "mint_lp")) {
_executeMintLp(line);
} else if (_eq(op, "burn_lp")) {
_executeBurnLp(line);
} else if (_eq(op, "buy_recenter_loop")) {
_executeBuyRecenterLoop(line);
} else if (_eq(op, "mine")) {
uint256 blocks = vm.parseJsonUint(line, ".blocks");
vm.roll(block.number + blocks);
} else {
console.log(string.concat("AttackRunner: unknown op '", op, "' -- skipping (check attack file for typos)"));
}
}
/// @dev Swap WETH→KRK via SwapRouter02.
function _executeBuy(string memory line) internal {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
vm.startBroadcast(ADV_PK);
ISwapRouter02(swapRouter).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: krkAddr,
fee: POOL_FEE,
recipient: advAddr,
amountIn: amount,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
})
);
vm.stopBroadcast();
}
/**
* @dev Batch buyrecenter loop. Executes `count` cycles of (buy `amount` WETH recenter),
* emitting a snapshot after each successful recenter. Using a single op instead of
* individual JSONL lines eliminates per-step forge-script dispatch overhead, allowing
* high-cycle attacks (e.g. 80 cycles) to complete within the fitness evaluation budget.
*
* Fields: count (uint), amount (wei string)
*/
function _executeBuyRecenterLoop(string memory line) internal {
uint256 count = vm.parseJsonUint(line, ".count");
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
for (uint256 i = 0; i < count; i++) {
// Buy WETH→KRK.
vm.startBroadcast(ADV_PK);
ISwapRouter02(swapRouter).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({
tokenIn: WETH,
tokenOut: krkAddr,
fee: POOL_FEE,
recipient: advAddr,
amountIn: amount,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
})
);
vm.stopBroadcast();
// Recenter.
vm.startBroadcast(RECENTER_PK);
try ILM(lmAddr).recenter() returns (bool isUp) {
_lastRecenterIsUp = isUp;
_hasRecentered = true;
_logSnapshot(_seq++);
} catch {
console.log("recenter: skipped (amplitude not reached)");
}
vm.stopBroadcast();
}
}
/// @dev Swap KRK→WETH via SwapRouter02. amount="all" uses full adversary KRK balance.
function _executeSell(string memory line) internal {
string memory amtStr = vm.parseJsonString(line, ".amount");
uint256 amount = _eq(amtStr, "all") ? IERC20(krkAddr).balanceOf(advAddr) : vm.parseUint(amtStr);
if (amount == 0) return;
vm.startBroadcast(ADV_PK);
ISwapRouter02(swapRouter).exactInputSingle(
ISwapRouter02.ExactInputSingleParams({
tokenIn: krkAddr,
tokenOut: WETH,
fee: POOL_FEE,
recipient: advAddr,
amountIn: amount,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
})
);
vm.stopBroadcast();
}
/// @dev Stake KRK via Stake.snatch() with no snatching.
/// Attack files use the field key ".taxRateIndex" for backward compatibility;
/// the value is passed directly as a raw taxRate to Stake.snatch().
/// The returned positionId is appended to _stakedPositionIds so that
/// subsequent unstake ops can reference positions by 1-based index.
function _executeStake(string memory line) internal {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex")); // JSONL key kept for compat
vm.startBroadcast(ADV_PK);
uint256 newPositionId = IStake(stakeAddr).snatch(amount, advAddr, taxRate, new uint256[](0));
vm.stopBroadcast();
_stakedPositionIds.push(newPositionId);
}
/// @dev Exit a staking position.
/// positionId in the attack file is a 1-based index into _stakedPositionIds
/// (the positions created by stake ops in this run), not the raw on-chain ID.
/// Stake.nextPositionId starts at 654_321, so literal IDs like "1" would always
/// revert with PositionNotFound — the index-based lookup resolves the real ID.
function _executeUnstake(string memory line) internal {
uint256 positionIndex = vm.parseJsonUint(line, ".positionId");
require(positionIndex >= 1 && positionIndex <= _stakedPositionIds.length,
"AttackRunner: unstake positionId out of range (must be 1-based index of a prior stake op)");
uint256 realPositionId = _stakedPositionIds[positionIndex - 1];
vm.startBroadcast(ADV_PK);
IStake(stakeAddr).exitPosition(realPositionId);
vm.stopBroadcast();
}
/// @dev Mint a Uniswap V3 LP position via NPM.
function _executeMintLp(string memory line) internal {
int24 tickLower = int24(vm.parseJsonInt(line, ".tickLower"));
int24 tickUpper = int24(vm.parseJsonInt(line, ".tickUpper"));
uint256 amount0 = vm.parseUint(vm.parseJsonString(line, ".amount0"));
uint256 amount1 = vm.parseUint(vm.parseJsonString(line, ".amount1"));
// Determine token order for NPM params (token0 < token1 by address).
(address t0, address t1) = token0isWeth ? (WETH, krkAddr) : (krkAddr, WETH);
vm.startBroadcast(ADV_PK);
(uint256 tokenId,,,) = INonfungiblePositionManager(npmAddr).mint(
INonfungiblePositionManager.MintParams({
token0: t0,
token1: t1,
fee: POOL_FEE,
tickLower: tickLower,
tickUpper: tickUpper,
amount0Desired: amount0,
amount1Desired: amount1,
amount0Min: 0,
amount1Min: 0,
recipient: advAddr,
deadline: block.timestamp + 3600
})
);
vm.stopBroadcast();
_mintedLpTokenIds.push(tokenId);
}
/// @dev Burn a Uniswap V3 LP position (decreaseLiquidity + collect).
/// positionIndex in the attack file is a 1-based index into _mintedLpTokenIds
/// (the LP positions created by mint_lp ops in this run), not a raw on-chain
/// NFT token ID. This makes burn_lp fork-block-independent — the actual tokenId
/// is resolved at runtime from the mint_lp that created it.
function _executeBurnLp(string memory line) internal {
uint256 positionIndex = vm.parseJsonUint(line, ".positionIndex");
require(positionIndex >= 1 && positionIndex <= _mintedLpTokenIds.length,
"AttackRunner: burn_lp positionIndex out of range (must be 1-based index of a prior mint_lp op)");
uint256 tokenId = _mintedLpTokenIds[positionIndex - 1];
// Read current liquidity for this token.
(,,,,,,,uint128 liquidity,,,,) = INonfungiblePositionManager(npmAddr).positions(tokenId);
if (liquidity == 0) {
console.log(string.concat(
"burn_lp: WARNING - tokenId ",
vm.toString(tokenId),
" (positionIndex ",
vm.toString(positionIndex),
") has zero liquidity, skipping (possible fork-block mismatch)"
));
return;
}
vm.startBroadcast(ADV_PK);
INonfungiblePositionManager(npmAddr).decreaseLiquidity(
INonfungiblePositionManager.DecreaseLiquidityParams({
tokenId: tokenId,
liquidity: liquidity,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp + 3600
})
);
INonfungiblePositionManager(npmAddr).collect(
INonfungiblePositionManager.CollectParams({
tokenId: tokenId,
recipient: advAddr,
amount0Max: type(uint128).max,
amount1Max: type(uint128).max
})
);
vm.stopBroadcast();
}
// ─── State snapshot ───────────────────────────────────────────────────────
/**
* @notice Emit a full state snapshot as a JSON line on stdout.
* @param seq Sequence number (0 = initial, N = after Nth recenter, final = last).
*/
function _logSnapshot(uint256 seq) internal view {
(uint160 sqrtPriceX96, int24 tick,,,,,) = pool.slot0();
// LM free balances.
uint256 lmEthFree = lmAddr.balance;
uint256 lmWethFree = IERC20(WETH).balanceOf(lmAddr);
// Position ETH values (using LiquidityAmounts at current sqrtPrice).
(uint128 fLiq, int24 fLo, int24 fHi) = ILM(lmAddr).positions(0); // FLOOR
(uint128 aLiq, int24 aLo, int24 aHi) = ILM(lmAddr).positions(1); // ANCHOR
(uint128 dLiq, int24 dLo, int24 dHi) = ILM(lmAddr).positions(2); // DISCOVERY
uint256 fEthValue = _positionEthValue(sqrtPriceX96, fLo, fHi, fLiq);
uint256 aEthValue = _positionEthValue(sqrtPriceX96, aLo, aHi, aLiq);
uint256 dEthValue = _positionEthValue(sqrtPriceX96, dLo, dHi, dLiq);
uint256 lmEthTotal = lmEthFree + lmWethFree + fEthValue + aEthValue + dEthValue;
// VWAP.
uint256 vwapX96 = ILM(lmAddr).getVWAP();
int24 vwapTick = _computeVwapTick(vwapX96);
// Token supply.
uint256 outstandingSupply = IKraiken(krkAddr).outstandingSupply();
uint256 totalSupply = IERC20(krkAddr).totalSupply();
// Optimizer output (may revert if optimizer doesn't implement getLiquidityParams).
uint256 anchorShare;
uint256 capitalInefficiency;
uint24 anchorWidth;
uint256 discoveryDepth;
try IOptimizer(optAddr).getLiquidityParams() returns (uint256 ci, uint256 as_, uint24 aw, uint256 dd) {
capitalInefficiency = ci;
anchorShare = as_;
anchorWidth = aw;
discoveryDepth = dd;
} catch { }
// Adversary balances.
uint256 advEth = advAddr.balance;
uint256 advKrk = IERC20(krkAddr).balanceOf(advAddr);
// Emit snapshot as a single JSON line.
// NOTE: pool.slot0() is read as a view call; forge-std finalises broadcast state before
// executing view calls, so the sqrtPriceX96/tick values are always post-broadcast.
console.log(_buildSnapshotJson(
seq, tick, lmEthFree, lmWethFree, lmEthTotal,
fLiq, fLo, fHi, fEthValue,
aLiq, aLo, aHi, aEthValue,
dLiq, dLo, dHi, dEthValue,
vwapX96, vwapTick,
outstandingSupply, totalSupply,
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
advEth, advKrk,
_lastRecenterIsUp, _hasRecentered
));
}
// ─── JSON builder ─────────────────────────────────────────────────────────
/// @dev Builds the snapshot JSON string. Split into a helper to avoid stack-too-deep.
function _buildSnapshotJson(
uint256 seq,
int24 tick,
uint256 lmEthFree,
uint256 lmWethFree,
uint256 lmEthTotal,
uint128 fLiq,
int24 fLo,
int24 fHi,
uint256 fEthValue,
uint128 aLiq,
int24 aLo,
int24 aHi,
uint256 aEthValue,
uint128 dLiq,
int24 dLo,
int24 dHi,
uint256 dEthValue,
uint256 vwapX96,
int24 vwapTick,
uint256 outstandingSupply,
uint256 totalSupply,
uint256 anchorShare,
uint256 capitalInefficiency,
uint24 anchorWidth,
uint256 discoveryDepth,
uint256 advEth,
uint256 advKrk,
bool recenterIsUp,
bool hasRecentered
)
internal
pure
returns (string memory)
{
return string.concat(
_snapshotHeader(seq, tick, lmEthFree, lmWethFree, lmEthTotal),
_snapshotPositions(fLiq, fLo, fHi, fEthValue, aLiq, aLo, aHi, aEthValue, dLiq, dLo, dHi, dEthValue),
_snapshotFooter(
vwapX96, vwapTick,
outstandingSupply, totalSupply,
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
advEth, advKrk,
recenterIsUp, hasRecentered
)
);
}
function _snapshotHeader(
uint256 seq,
int24 tick,
uint256 lmEthFree,
uint256 lmWethFree,
uint256 lmEthTotal
)
internal
pure
returns (string memory)
{
return string.concat(
'{"seq":',
seq.str(),
',"tick":',
int256(tick).istr(),
',"lm_eth_free":"',
lmEthFree.str(),
'","lm_weth_free":"',
lmWethFree.str(),
'","lm_eth_total":"',
lmEthTotal.str(),
'"'
);
}
function _snapshotPositions(
uint128 fLiq,
int24 fLo,
int24 fHi,
uint256 fEthValue,
uint128 aLiq,
int24 aLo,
int24 aHi,
uint256 aEthValue,
uint128 dLiq,
int24 dLo,
int24 dHi,
uint256 dEthValue
)
internal
pure
returns (string memory)
{
return string.concat(
',"positions":{"floor":{"liquidity":"',
uint256(fLiq).str(),
'","tickLower":',
int256(fLo).istr(),
',"tickUpper":',
int256(fHi).istr(),
',"ethValue":"',
fEthValue.str(),
'"},"anchor":{"liquidity":"',
uint256(aLiq).str(),
'","tickLower":',
int256(aLo).istr(),
',"tickUpper":',
int256(aHi).istr(),
',"ethValue":"',
aEthValue.str(),
'"},"discovery":{"liquidity":"',
uint256(dLiq).str(),
'","tickLower":',
int256(dLo).istr(),
',"tickUpper":',
int256(dHi).istr(),
',"ethValue":"',
dEthValue.str(),
'"}}' // close discovery{} then positions{}; root object is closed by _snapshotFooter
);
}
function _snapshotFooter(
uint256 vwapX96,
int24 vwapTick,
uint256 outstandingSupply,
uint256 totalSupply,
uint256 anchorShare,
uint256 capitalInefficiency,
uint24 anchorWidth,
uint256 discoveryDepth,
uint256 advEth,
uint256 advKrk,
bool recenterIsUp,
bool hasRecentered
)
internal
pure
returns (string memory)
{
return string.concat(
',"vwap_x96":"',
vwapX96.str(),
'","vwap_tick":',
int256(vwapTick).istr(),
',"outstanding_supply":"',
outstandingSupply.str(),
'","total_supply":"',
totalSupply.str(),
'","optimizer_output":{"anchorShare":"',
anchorShare.str(),
'","capitalInefficiency":"',
capitalInefficiency.str(),
'","anchorWidth":',
uint256(anchorWidth).str(),
',"discoveryWidth":"',
discoveryDepth.str(),
'"},"adversary_eth":"',
advEth.str(),
'","adversary_krk":"',
advKrk.str(),
// Emit null before the first recenter so downstream parsers can distinguish
// "no recenter yet" from "last recenter moved price down" (false).
'","recenter_is_up":',
hasRecentered ? (recenterIsUp ? "true" : "false") : "null",
"}"
);
}
// ─── Math helpers ─────────────────────────────────────────────────────────
/**
* @notice Compute the total ETH-equivalent value of a liquidity position at the current pool price.
* @dev Uses LiquidityAmounts.getAmountsForLiquidity which handles all three cases:
* fully below range (all token0), fully above range (all token1), and in-range (split).
* Both the ETH component and the KRK component (converted to ETH at current sqrtPriceX96)
* are summed so that lm_eth_total accurately reflects TVL regardless of price range.
*
* KRKETH conversion:
* If token0=WETH: price = KRK/WETH = sqrtP^2/2^192
* krkInEth = krk * 2^192 / sqrtP^2 = mulDiv(mulDiv(krk, 2^96, sqrtP), 2^96, sqrtP)
* If token0=KRK: price = WETH/KRK = sqrtP^2/2^192
* krkInEth = krk * sqrtP^2 / 2^192 = mulDiv(mulDiv(krk, sqrtP, 2^96), sqrtP, 2^96)
*/
function _positionEthValue(
uint160 sqrtPriceX96,
int24 tickLower,
int24 tickUpper,
uint128 liquidity
)
internal
view
returns (uint256)
{
if (liquidity == 0) return 0;
uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper);
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity);
uint256 ethAmount = token0isWeth ? amount0 : amount1;
uint256 krkAmount = token0isWeth ? amount1 : amount0;
uint256 krkInEth = 0;
if (krkAmount > 0 && sqrtPriceX96 > 0) {
if (token0isWeth) {
// token0=WETH, token1=KRK: 1 KRK = 2^192 / sqrtP^2 WETH
krkInEth = FullMath.mulDiv(
FullMath.mulDiv(krkAmount, 1 << 96, sqrtPriceX96),
1 << 96,
sqrtPriceX96
);
} else {
// token0=KRK, token1=WETH: 1 KRK = sqrtP^2 / 2^192 WETH
krkInEth = FullMath.mulDiv(
FullMath.mulDiv(krkAmount, sqrtPriceX96, 1 << 96),
sqrtPriceX96,
1 << 96
);
}
}
return ethAmount + krkInEth;
}
/**
* @notice Convert a VWAP X96 value to a pool tick.
* @dev VWAP is stored as price * 2^96 (Q96 format) by _priceAtTick in UniswapMath.
* To recover the tick: sqrt(vwapX96 / 2^96) = sqrtPrice, then getTickAtSqrtRatio.
* Shift right by 32 converts Q96 Q64 (ABDK 64x64 format).
*
* Overflow guard: int128(int256(vwapX96 >> 32)) wraps to a negative value when
* vwapX96 > 2^159 (extremely high price ratios outside realistic KRK/WETH ranges).
* The `priceRatioX64 <= 0` check catches this and returns tick=0 rather than reverting,
* so snapshots remain valid callers should treat vwap_tick=0 as "VWAP unavailable"
* when vwap_x96 is non-zero. Additionally, ABDKMath64x64.sqrt(priceRatioX64) << 32
* could overflow int128 before the uint160 cast for pathologically large prices, but
* this is unreachable for any token pair with price < 2^32 (covers all practical cases).
*
* @param vwapX96 The VWAP in Q96 format (as returned by LM.getVWAP()).
* @return The pool tick corresponding to the VWAP price, or 0 if vwapX96 is out of range.
*/
function _computeVwapTick(uint256 vwapX96) internal pure returns (int24) {
if (vwapX96 == 0) return 0;
int128 priceRatioX64 = int128(int256(vwapX96 >> 32));
if (priceRatioX64 <= 0) return 0; // vwapX96 > 2^159: out of representable range, report 0
uint160 sqrtPriceX96_ = uint160(int160(ABDKMath64x64.sqrt(priceRatioX64) << 32));
return TickMath.getTickAtSqrtRatio(sqrtPriceX96_);
}
// ─── Utility ──────────────────────────────────────────────────────────────
function _eq(string memory a, string memory b) internal pure returns (bool) {
return keccak256(bytes(a)) == keccak256(bytes(b));
}
function _deploymentsPath() internal view returns (string memory) {
try vm.envString("DEPLOYMENTS_FILE") returns (string memory path) {
if (bytes(path).length > 0) return path;
} catch { }
return "deployments-local.json";
}
}