diff --git a/onchain/script/backtesting/AttackRunner.s.sol b/onchain/script/backtesting/AttackRunner.s.sol new file mode 100644 index 0000000..3af548c --- /dev/null +++ b/onchain/script/backtesting/AttackRunner.s.sol @@ -0,0 +1,643 @@ +// 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"; + } +} diff --git a/onchain/script/backtesting/attacks/fee-drain-oscillation.jsonl b/onchain/script/backtesting/attacks/fee-drain-oscillation.jsonl new file mode 100644 index 0000000..2faafa2 --- /dev/null +++ b/onchain/script/backtesting/attacks/fee-drain-oscillation.jsonl @@ -0,0 +1,20 @@ +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} diff --git a/onchain/script/backtesting/attacks/il-crystallization-15.jsonl b/onchain/script/backtesting/attacks/il-crystallization-15.jsonl new file mode 100644 index 0000000..9aeeac3 --- /dev/null +++ b/onchain/script/backtesting/attacks/il-crystallization-15.jsonl @@ -0,0 +1,31 @@ +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} diff --git a/onchain/script/backtesting/attacks/il-crystallization-80.jsonl b/onchain/script/backtesting/attacks/il-crystallization-80.jsonl new file mode 100644 index 0000000..baf15fa --- /dev/null +++ b/onchain/script/backtesting/attacks/il-crystallization-80.jsonl @@ -0,0 +1,153 @@ +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"buy","amount":"100000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} diff --git a/onchain/script/backtesting/attacks/round-trip-safe.jsonl b/onchain/script/backtesting/attacks/round-trip-safe.jsonl new file mode 100644 index 0000000..8423be4 --- /dev/null +++ b/onchain/script/backtesting/attacks/round-trip-safe.jsonl @@ -0,0 +1,60 @@ +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} +{"op":"buy","amount":"10000000000000000000","token":"WETH"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} diff --git a/onchain/script/backtesting/attacks/staking-safe.jsonl b/onchain/script/backtesting/attacks/staking-safe.jsonl new file mode 100644 index 0000000..fe2f2ff --- /dev/null +++ b/onchain/script/backtesting/attacks/staking-safe.jsonl @@ -0,0 +1,14 @@ +{"op":"buy","amount":"50000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"stake","amount":"1000000000000000000000","taxRateIndex":0} +{"op":"recenter"} +{"op":"buy","amount":"50000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"stake","amount":"1000000000000000000000","taxRateIndex":5} +{"op":"recenter"} +{"op":"buy","amount":"50000000000000000000","token":"WETH"} +{"op":"recenter"} +{"op":"unstake","positionId":1} +{"op":"recenter"} +{"op":"sell","amount":"all","token":"KRK"} +{"op":"recenter"} diff --git a/scripts/harb-evaluator/export-attacks.py b/scripts/harb-evaluator/export-attacks.py new file mode 100755 index 0000000..8b8f1f4 --- /dev/null +++ b/scripts/harb-evaluator/export-attacks.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""export-attacks.py — Convert red-team stream JSONL to attack JSONL format. + +Parses a red-team-stream.jsonl file (produced by red-team.sh --output-format stream-json) +for tool_use blocks containing cast send commands, extracts the operation type and +parameters, and writes them in AttackRunner-compatible JSONL format. + +Usage: + python3 export-attacks.py [STREAM_FILE] [OUTPUT_FILE] + + STREAM_FILE Path to red-team-stream.jsonl (default: tmp/red-team-stream.jsonl) + OUTPUT_FILE Path to write attack JSONL (default: stdout) + +Supported cast send patterns: + WETH.deposit() → ignored (setup) + WETH/KRK.approve() → ignored (setup) + SwapRouter.exactInputSingle(...) → buy / sell + LM.recenter() → recenter + Stake.snatch(...) → stake + Stake.exitPosition(...) → unstake + NPM.mint(...) → mint_lp + NPM.decreaseLiquidity(...) → burn_lp (paired with collect) + evm_mine / anvil_snapshot etc. → mine (cast rpc evm_mine) + +Only operations with recognisable function signatures are emitted. +Unrecognised calls are silently skipped. +""" + +import json +import re +import sys +from pathlib import Path + +# ── Constants (must match red-team.sh and AttackRunner.s.sol) ────────────────── +WETH_ADDR = "0x4200000000000000000000000000000000000006" +SWAP_ROUTER_ADDR = "0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4" +NPM_ADDR = "0x27F971cb582BF9E50F397e4d29a5C7A34f11faA2" + + +def _normalise_addr(addr: str) -> str: + return addr.lower().strip() + + +def _extract_cast_commands(stream_file: str) -> list[dict]: + """Parse stream-json and return a list of parsed cast send invocations.""" + commands = [] + try: + with open(stream_file) as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + + # Look for tool_use blocks with bash commands. + msg = obj.get("message", {}) + for block in msg.get("content", []): + if block.get("type") != "tool_use": + continue + if block.get("name") not in ("Bash", "bash"): + continue + cmd_input = block.get("input", {}) + cmd = cmd_input.get("command", "") + if not cmd: + continue + parsed = _parse_cast_command(cmd) + if parsed: + commands.append(parsed) + except FileNotFoundError: + print(f"Error: stream file not found: {stream_file}", file=sys.stderr) + sys.exit(1) + return commands + + +def _parse_cast_command(cmd: str) -> dict | None: + """ + Parse a single shell command string and extract an attack operation dict. + Returns None if the command is not a recognised attack operation. + """ + # Normalise whitespace / line continuations. + cmd = re.sub(r"\\\n\s*", " ", cmd).strip() + + # Must be a cast send (not cast call / cast rpc / etc). + if "cast rpc" in cmd and "evm_mine" in cmd: + return {"op": "mine", "blocks": 1} + + if "cast send" not in cmd: + return None + + # Extract destination address (first non-flag positional after "cast send"). + dest_match = re.search(r"cast send\s+(?:\S+\s+)*?(0x[0-9a-fA-F]{40})", cmd) + if not dest_match: + return None + dest = _normalise_addr(dest_match.group(1)) + + # Extract function signature (the quoted sig after the address). + sig_match = re.search(r'"([a-zA-Z_]\w*\([^"]*\))"', cmd) + if not sig_match: + return None + sig = sig_match.group(1) + func_name = sig.split("(")[0].strip() + + # Extract positional arguments (the tuple or bare args after the signature). + args_text = cmd[sig_match.end():].strip() + + # ── dispatch by function ────────────────────────────────────────────────── + + if func_name == "deposit": + return None # WETH setup, skip + + if func_name == "approve": + return None # token approval, skip + + if func_name == "exactInputSingle": + return _parse_swap(args_text) + + if func_name == "recenter": + return {"op": "recenter"} + + if func_name == "snatch": + return _parse_snatch(args_text) + + if func_name == "exitPosition": + return _parse_exit_position(args_text) + + if func_name == "mint" and _normalise_addr(dest_match.group(1)) == _normalise_addr(NPM_ADDR): + return _parse_mint_lp(args_text) + + if func_name == "decreaseLiquidity": + return _parse_burn_lp(args_text) + + if func_name == "collect": + return None # paired with decreaseLiquidity, handled there + + return None + + +def _extract_tuple_args(args_text: str) -> list[str]: + """ + Extract the positional elements from a Solidity tuple literal: (a,b,c,...). + Handles nested parentheses. + """ + args_text = args_text.strip() + if args_text.startswith('"') or args_text.startswith("'"): + args_text = args_text[1:] + if args_text.startswith("("): + # Find matching closing paren. + depth = 0 + for i, ch in enumerate(args_text): + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + args_text = args_text[1:i] + break + + # Split on commas, respecting nested parens. + parts = [] + depth = 0 + current = [] + for ch in args_text: + if ch == "(": + depth += 1 + current.append(ch) + elif ch == ")": + depth -= 1 + current.append(ch) + elif ch == "," and depth == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(ch) + if current: + parts.append("".join(current).strip()) + + return parts + + +def _clean_value(v: str) -> str: + return v.strip().strip('"').strip("'") + + +def _parse_swap(args_text: str) -> dict | None: + """ + Parse exactInputSingle((tokenIn,tokenOut,fee,recipient,amountIn,amountOutMin,sqrtLimit)). + SwapRouter02 struct order: tokenIn, tokenOut, fee, recipient, amountIn, amountOutMinimum, sqrtPriceLimitX96 + """ + parts = _extract_tuple_args(args_text) + if len(parts) < 7: + return None + + token_in = _clean_value(parts[0]).lower() + token_out = _clean_value(parts[1]).lower() + amount_in = _clean_value(parts[4]) + + weth = _normalise_addr(WETH_ADDR) + + if token_in == weth: + # WETH → KRK: buy + return {"op": "buy", "amount": amount_in, "token": "WETH"} + else: + # KRK → WETH: sell + return {"op": "sell", "amount": amount_in, "token": "KRK"} + + +def _parse_snatch(args_text: str) -> dict | None: + """ + Parse snatch(assets, receiver, taxRateIndex, positionsToSnatch[]). + """ + # Strip outer quotes if present, then split bare args. + parts = args_text.strip().split() + if len(parts) < 3: + return None + amount = _clean_value(parts[0]) + # parts[1] is receiver address, parts[2] is taxRateIndex + try: + tax_rate_index = int(_clean_value(parts[2])) + except ValueError: + tax_rate_index = 0 + return {"op": "stake", "amount": amount, "taxRateIndex": tax_rate_index} + + +def _parse_exit_position(args_text: str) -> dict | None: + """Parse exitPosition(positionId).""" + parts = args_text.strip().split() + if not parts: + return None + try: + position_id = int(_clean_value(parts[0])) + except ValueError: + return None + return {"op": "unstake", "positionId": position_id} + + +def _parse_mint_lp(args_text: str) -> dict | None: + """ + Parse NPM.mint((token0,token1,fee,tickLower,tickUpper,amount0,amount1,min0,min1,recipient,deadline)). + """ + parts = _extract_tuple_args(args_text) + if len(parts) < 7: + return None + try: + tick_lower = int(_clean_value(parts[3])) + tick_upper = int(_clean_value(parts[4])) + amount0 = _clean_value(parts[5]) + amount1 = _clean_value(parts[6]) + except (ValueError, IndexError): + return None + return { + "op": "mint_lp", + "tickLower": tick_lower, + "tickUpper": tick_upper, + "amount0": amount0, + "amount1": amount1, + } + + +def _parse_burn_lp(args_text: str) -> dict | None: + """ + Parse NPM.decreaseLiquidity((tokenId,liquidity,min0,min1,deadline)). + """ + parts = _extract_tuple_args(args_text) + if not parts: + return None + try: + token_id = int(_clean_value(parts[0])) + except (ValueError, IndexError): + return None + return {"op": "burn_lp", "tokenId": token_id} + + +def main() -> None: + stream_file = sys.argv[1] if len(sys.argv) > 1 else "tmp/red-team-stream.jsonl" + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + ops = _extract_cast_commands(stream_file) + + if not ops: + print("Warning: no attack operations found in stream file.", file=sys.stderr) + + lines = [json.dumps(op, separators=(",", ":")) for op in ops] + output = "\n".join(lines) + if output: + output += "\n" + + if output_file: + Path(output_file).write_text(output) + print(f"Wrote {len(ops)} operations to {output_file}", file=sys.stderr) + else: + print(output, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/harb-evaluator/red-team.sh b/scripts/harb-evaluator/red-team.sh index bcc4b04..1344148 100755 --- a/scripts/harb-evaluator/red-team.sh +++ b/scripts/harb-evaluator/red-team.sh @@ -18,13 +18,16 @@ set -euo pipefail CAST=/home/debian/.foundry/bin/cast +FORGE=/home/debian/.foundry/bin/forge RPC_URL="${RPC_URL:-http://localhost:8545}" CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" REPORT_DIR="$REPO_ROOT/tmp" REPORT="$REPORT_DIR/red-team-report.txt" STREAM_LOG="$REPORT_DIR/red-team-stream.jsonl" -MEMORY_FILE="$REPORT_DIR/red-team-memory.jsonl" +MEMORY_FILE="$REPO_ROOT/tmp/red-team-memory.jsonl" +ATTACK_EXPORT="$REPORT_DIR/red-team-attacks.jsonl" +ATTACK_SNAPSHOTS="$REPORT_DIR/red-team-snapshots.jsonl" DEPLOYMENTS="$REPO_ROOT/onchain/deployments-local.json" # ── Anvil accounts ───────────────────────────────────────────────────────────── @@ -45,7 +48,8 @@ log() { echo "[red-team] $*"; } die() { echo "[red-team] ERROR: $*" >&2; exit 2; } # ── Prerequisites ────────────────────────────────────────────────────────────── -command -v "$CAST" &>/dev/null || die "cast not found at $CAST" +command -v "$CAST" &>/dev/null || die "cast not found at $CAST" +command -v "$FORGE" &>/dev/null || die "forge not found at $FORGE" command -v claude &>/dev/null || die "claude CLI not found (install: npm i -g @anthropic-ai/claude-code)" command -v python3 &>/dev/null || die "python3 not found" command -v jq &>/dev/null || die "jq not found" @@ -615,6 +619,42 @@ log "Extracting strategy findings from agent output..." extract_memory "$STREAM_LOG" log " floor_after = $FLOOR_AFTER wei/token" +# ── 8b. Export attack sequence and replay with AttackRunner ──────────────────── +# Converts the agent's cast send commands to structured JSONL and replays them +# via AttackRunner.s.sol to capture full state snapshots for optimizer training. +log "Exporting attack sequence from stream log..." +set +e +python3 "$REPO_ROOT/scripts/harb-evaluator/export-attacks.py" \ + "$STREAM_LOG" "$ATTACK_EXPORT" 2>&1 | while IFS= read -r line; do log " $line"; done +EXPORT_EXIT=${PIPESTATUS[0]} +set -e + +if [[ $EXPORT_EXIT -eq 0 && -f "$ATTACK_EXPORT" && -s "$ATTACK_EXPORT" ]]; then + log " Attack export: $ATTACK_EXPORT" + log " Replaying attack sequence with AttackRunner for state snapshots..." + set +e + (cd "$REPO_ROOT/onchain" && \ + ATTACK_FILE="$ATTACK_EXPORT" \ + DEPLOYMENTS_FILE="deployments-local.json" \ + "$FORGE" script script/backtesting/AttackRunner.s.sol \ + --rpc-url "$RPC_URL" --broadcast 2>&1 \ + | grep '^{' >"$ATTACK_SNAPSHOTS") + REPLAY_EXIT=$? + set -e + if [[ $REPLAY_EXIT -eq 0 && -s "$ATTACK_SNAPSHOTS" ]]; then + SNAPSHOT_COUNT=$(wc -l <"$ATTACK_SNAPSHOTS") + log " AttackRunner replay complete: $SNAPSHOT_COUNT snapshots → $ATTACK_SNAPSHOTS" + else + log " WARNING: AttackRunner replay produced no snapshots (exit $REPLAY_EXIT) — non-fatal" + fi + # Revert to the clean baseline after replay so the floor check below is unaffected. + "$CAST" rpc anvil_revert "$SNAP" --rpc-url "$RPC_URL" >/dev/null 2>&1 || true + # Re-take the snapshot so cleanup trap still has a valid ID to revert. + SNAP=$("$CAST" rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"') +else + log " WARNING: No attack operations exported from stream — skipping AttackRunner replay" +fi + # ── 9. Summarise results ─────────────────────────────────────────────────────── log "" log "=== RED-TEAM SUMMARY ==="