644 lines
24 KiB
Solidity
644 lines
24 KiB
Solidity
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
|
pragma solidity ^0.8.19;
|
||
|
|
|
||
|
|
import { FormatLib } from "./FormatLib.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 {
|
||
|
|
function snatch(uint256 assets, address receiver, uint32 taxRateIndex, uint256[] calldata positionsToSnatch) external;
|
||
|
|
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 WETH→KRK via SwapRouter. Fields: amount (wei string), token (ignored, WETH assumed)
|
||
|
|
* sell Swap KRK→WETH via SwapRouter. Fields: amount (wei string or "all"), token (ignored)
|
||
|
|
* recenter Call LM.recenter() via recenterAccess account. Emits a snapshot.
|
||
|
|
* stake Call Stake.snatch(). Fields: amount (wei string), taxRateIndex (0-29)
|
||
|
|
* 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: tokenId
|
||
|
|
* 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 SWAP_ROUTER = 0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4;
|
||
|
|
address internal constant NPM_ADDR = 0x27F971cb582BF9E50F397e4d29a5C7A34f11faA2;
|
||
|
|
address internal constant V3_FACTORY = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24;
|
||
|
|
|
||
|
|
// ─── 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 advAddr;
|
||
|
|
address internal recenterAddr;
|
||
|
|
address internal lmAddr;
|
||
|
|
address internal krkAddr;
|
||
|
|
address internal stakeAddr;
|
||
|
|
address internal optAddr;
|
||
|
|
IUniswapV3Pool internal pool;
|
||
|
|
bool internal token0isWeth;
|
||
|
|
|
||
|
|
// ─── Entry point ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function run() external {
|
||
|
|
// 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(V3_FACTORY).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");
|
||
|
|
uint256 seq = 1;
|
||
|
|
string memory line = vm.readLine(attackFile);
|
||
|
|
while (bytes(line).length > 0) {
|
||
|
|
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);
|
||
|
|
IWETH9(WETH).deposit{ value: 1_000 ether }();
|
||
|
|
IERC20(WETH).approve(SWAP_ROUTER, type(uint256).max);
|
||
|
|
IERC20(WETH).approve(NPM_ADDR, type(uint256).max);
|
||
|
|
IERC20(krkAddr).approve(SWAP_ROUTER, type(uint256).max);
|
||
|
|
IERC20(krkAddr).approve(stakeAddr, type(uint256).max);
|
||
|
|
IERC20(krkAddr).approve(NPM_ADDR, 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);
|
||
|
|
ILM(lmAddr).recenter();
|
||
|
|
vm.stopBroadcast();
|
||
|
|
isRecenter = true;
|
||
|
|
} 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, "mine")) {
|
||
|
|
uint256 blocks = vm.parseJsonUint(line, ".blocks");
|
||
|
|
vm.roll(block.number + blocks);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @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(SWAP_ROUTER).exactInputSingle(
|
||
|
|
ISwapRouter02.ExactInputSingleParams({
|
||
|
|
tokenIn: WETH,
|
||
|
|
tokenOut: krkAddr,
|
||
|
|
fee: POOL_FEE,
|
||
|
|
recipient: advAddr,
|
||
|
|
amountIn: amount,
|
||
|
|
amountOutMinimum: 0,
|
||
|
|
sqrtPriceLimitX96: 0
|
||
|
|
})
|
||
|
|
);
|
||
|
|
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(SWAP_ROUTER).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.
|
||
|
|
function _executeStake(string memory line) internal {
|
||
|
|
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
|
||
|
|
uint32 taxRateIndex = uint32(vm.parseJsonUint(line, ".taxRateIndex"));
|
||
|
|
vm.startBroadcast(ADV_PK);
|
||
|
|
IStake(stakeAddr).snatch(amount, advAddr, taxRateIndex, new uint256[](0));
|
||
|
|
vm.stopBroadcast();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @dev Exit a staking position.
|
||
|
|
function _executeUnstake(string memory line) internal {
|
||
|
|
uint256 positionId = vm.parseJsonUint(line, ".positionId");
|
||
|
|
vm.startBroadcast(ADV_PK);
|
||
|
|
IStake(stakeAddr).exitPosition(positionId);
|
||
|
|
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);
|
||
|
|
INonfungiblePositionManager(NPM_ADDR).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();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// @dev Burn a Uniswap V3 LP position (decreaseLiquidity + collect).
|
||
|
|
function _executeBurnLp(string memory line) internal {
|
||
|
|
uint256 tokenId = vm.parseJsonUint(line, ".tokenId");
|
||
|
|
|
||
|
|
// Read current liquidity for this token.
|
||
|
|
(,,,,,,,uint128 liquidity,,,,) = INonfungiblePositionManager(NPM_ADDR).positions(tokenId);
|
||
|
|
if (liquidity == 0) return;
|
||
|
|
|
||
|
|
vm.startBroadcast(ADV_PK);
|
||
|
|
INonfungiblePositionManager(NPM_ADDR).decreaseLiquidity(
|
||
|
|
INonfungiblePositionManager.DecreaseLiquidityParams({
|
||
|
|
tokenId: tokenId,
|
||
|
|
liquidity: liquidity,
|
||
|
|
amount0Min: 0,
|
||
|
|
amount1Min: 0,
|
||
|
|
deadline: block.timestamp + 3600
|
||
|
|
})
|
||
|
|
);
|
||
|
|
INonfungiblePositionManager(NPM_ADDR).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.
|
||
|
|
console.log(_buildSnapshotJson(
|
||
|
|
seq, tick, lmEthFree, lmWethFree, lmEthTotal,
|
||
|
|
fLiq, fLo, fHi, fEthValue,
|
||
|
|
aLiq, aLo, aHi, aEthValue,
|
||
|
|
dLiq, dLo, dHi,
|
||
|
|
vwapX96, vwapTick,
|
||
|
|
outstandingSupply, totalSupply,
|
||
|
|
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
|
||
|
|
advEth, advKrk
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 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 vwapX96,
|
||
|
|
int24 vwapTick,
|
||
|
|
uint256 outstandingSupply,
|
||
|
|
uint256 totalSupply,
|
||
|
|
uint256 anchorShare,
|
||
|
|
uint256 capitalInefficiency,
|
||
|
|
uint24 anchorWidth,
|
||
|
|
uint256 discoveryDepth,
|
||
|
|
uint256 advEth,
|
||
|
|
uint256 advKrk
|
||
|
|
)
|
||
|
|
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),
|
||
|
|
_snapshotFooter(
|
||
|
|
vwapX96, vwapTick,
|
||
|
|
outstandingSupply, totalSupply,
|
||
|
|
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
|
||
|
|
advEth, advKrk
|
||
|
|
)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
)
|
||
|
|
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(),
|
||
|
|
'"}}'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _snapshotFooter(
|
||
|
|
uint256 vwapX96,
|
||
|
|
int24 vwapTick,
|
||
|
|
uint256 outstandingSupply,
|
||
|
|
uint256 totalSupply,
|
||
|
|
uint256 anchorShare,
|
||
|
|
uint256 capitalInefficiency,
|
||
|
|
uint24 anchorWidth,
|
||
|
|
uint256 discoveryDepth,
|
||
|
|
uint256 advEth,
|
||
|
|
uint256 advKrk
|
||
|
|
)
|
||
|
|
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(),
|
||
|
|
'"}'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Math helpers ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @notice Compute the ETH 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).
|
||
|
|
* ETH is token0 when token0isWeth, token1 otherwise.
|
||
|
|
*/
|
||
|
|
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);
|
||
|
|
return token0isWeth ? amount0 : amount1;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @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).
|
||
|
|
* @param vwapX96 The VWAP in Q96 format (as returned by LM.getVWAP()).
|
||
|
|
* @return The pool tick corresponding to the VWAP price.
|
||
|
|
*/
|
||
|
|
function _computeVwapTick(uint256 vwapX96) internal pure returns (int24) {
|
||
|
|
if (vwapX96 == 0) return 0;
|
||
|
|
int128 priceRatioX64 = int128(int256(vwapX96 >> 32));
|
||
|
|
if (priceRatioX64 <= 0) return 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";
|
||
|
|
}
|
||
|
|
}
|