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