fix: Backtesting: replay red-team attack sequences against optimizer candidates (#536)

- Add AttackRunner.s.sol: structured forge script that reads attack ops from a
  JSONL file (ATTACK_FILE env), executes them against the local Anvil deployment,
  and emits full state snapshots (tick, positions, VWAP, optimizer output,
  adversary balances) as JSON lines after every recenter and at start/end.

- Add 5 canonical attack files in onchain/script/backtesting/attacks/:
  * il-crystallization-15.jsonl  — 15 buy-recenter cycles + sell (extraction)
  * il-crystallization-80.jsonl  — 80 buy-recenter cycles + sell (extraction)
  * fee-drain-oscillation.jsonl  — buy-recenter-sell-recenter oscillation
  * round-trip-safe.jsonl        — 20 full round-trips (regression: safe)
  * staking-safe.jsonl           — staking manipulation (regression: safe)

- Add scripts/harb-evaluator/export-attacks.py: parses red-team-stream.jsonl
  for tool_use Bash blocks containing cast send commands and converts them to
  AttackRunner-compatible JSONL (buy/sell/recenter/stake/unstake/mint_lp/burn_lp).

- Update scripts/harb-evaluator/red-team.sh: after each agent run, automatically
  exports the attack sequence via export-attacks.py and replays it with
  AttackRunner to capture structured snapshots in tmp/red-team-snapshots.jsonl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-11 02:08:06 +00:00
parent 08b9a3df30
commit c8453f6a33
8 changed files with 1261 additions and 2 deletions

View file

@ -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 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.
* 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 WETHKRK 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 KRKWETH 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";
}
}

View file

@ -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"}

View file

@ -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"}

View file

@ -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"}

View file

@ -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"}

View file

@ -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"}

View file

@ -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()

View file

@ -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 ==="