Merge pull request 'fix: Backtesting: replay red-team attack sequences against optimizer candidates (#536)' (#565) from fix/issue-536 into master

This commit is contained in:
johba 2026-03-11 19:24:27 +01:00
commit 514a55a1ac
8 changed files with 1335 additions and 2 deletions

View file

@ -0,0 +1,717 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { FormatLib } from "./FormatLib.sol";
import { FullMath } from "@aperture/uni-v3-lib/FullMath.sol";
import { LiquidityAmounts } from "@aperture/uni-v3-lib/LiquidityAmounts.sol";
import { TickMath } from "@aperture/uni-v3-lib/TickMath.sol";
import { ABDKMath64x64 } from "@abdk/ABDKMath64x64.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
// Minimal external interfaces
interface IWETH9 {
function deposit() external payable;
}
interface ISwapRouter02 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams calldata params) external returns (uint256 amountOut);
}
interface ILM {
function getVWAP() external view returns (uint256);
function positions(uint8 stage) external view returns (uint128 liquidity, int24 tickLower, int24 tickUpper);
function recenter() external returns (bool);
}
interface IKraiken is IERC20 {
function outstandingSupply() external view returns (uint256);
}
interface IOptimizer {
function getLiquidityParams()
external
view
returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth);
}
interface IStake {
// taxRate matches the actual Stake.sol parameter name (a raw rate value, not a lookup index)
function snatch(uint256 assets, address receiver, uint32 taxRate, uint256[] calldata positionsToSnatch) external;
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 (raw taxRate value passed to Stake.snatch)
* unstake Call Stake.exitPosition(). Fields: positionId
* mint_lp Add LP via NPM. Fields: tickLower, tickUpper, amount0 (wei string), amount1 (wei string)
* burn_lp Remove LP via NPM. Fields: 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;
/// @dev Direction of the most recent recenter: true = price moved up, false = price moved down.
/// Read by _logSnapshot to include in post-recenter snapshots.
bool internal _lastRecenterIsUp;
/// @dev Set to true after the first recenter call. Allows _logSnapshot to emit
/// recenter_is_up=null on the initial snapshot (before any recenter has occurred)
/// rather than the ambiguous false default.
bool internal _hasRecentered;
// 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);
// Wrap most of the adversary's ETH (leave 1 ETH for gas).
// The adversary starts with 10 000 ETH; wrapping 9 000 covers the heaviest buy sequences.
IWETH9(WETH).deposit{ value: 9_000 ether }();
IERC20(WETH).approve(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);
// Capture direction: true = price moved up, false = price moved down.
// recenter() reverts (not returns false) when amplitude is insufficient,
// so a successful call is always a real recenter regardless of direction.
_lastRecenterIsUp = ILM(lmAddr).recenter();
_hasRecentered = true;
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);
} else {
console.log(string.concat("AttackRunner: unknown op '", op, "' -- skipping (check attack file for typos)"));
}
}
/// @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.
/// Attack files use the field key ".taxRateIndex" for backward compatibility;
/// the value is passed directly as a raw taxRate to Stake.snatch().
function _executeStake(string memory line) internal {
uint256 amount = vm.parseUint(vm.parseJsonString(line, ".amount"));
uint32 taxRate = uint32(vm.parseJsonUint(line, ".taxRateIndex")); // JSONL key kept for compat
vm.startBroadcast(ADV_PK);
IStake(stakeAddr).snatch(amount, advAddr, taxRate, 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.
// NOTE: pool.slot0() is read as a view call; forge-std finalises broadcast state before
// executing view calls, so the sqrtPriceX96/tick values are always post-broadcast.
console.log(_buildSnapshotJson(
seq, tick, lmEthFree, lmWethFree, lmEthTotal,
fLiq, fLo, fHi, fEthValue,
aLiq, aLo, aHi, aEthValue,
dLiq, dLo, dHi, dEthValue,
vwapX96, vwapTick,
outstandingSupply, totalSupply,
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
advEth, advKrk,
_lastRecenterIsUp, _hasRecentered
));
}
// JSON builder
/// @dev Builds the snapshot JSON string. Split into a helper to avoid stack-too-deep.
function _buildSnapshotJson(
uint256 seq,
int24 tick,
uint256 lmEthFree,
uint256 lmWethFree,
uint256 lmEthTotal,
uint128 fLiq,
int24 fLo,
int24 fHi,
uint256 fEthValue,
uint128 aLiq,
int24 aLo,
int24 aHi,
uint256 aEthValue,
uint128 dLiq,
int24 dLo,
int24 dHi,
uint256 dEthValue,
uint256 vwapX96,
int24 vwapTick,
uint256 outstandingSupply,
uint256 totalSupply,
uint256 anchorShare,
uint256 capitalInefficiency,
uint24 anchorWidth,
uint256 discoveryDepth,
uint256 advEth,
uint256 advKrk,
bool recenterIsUp,
bool hasRecentered
)
internal
pure
returns (string memory)
{
return string.concat(
_snapshotHeader(seq, tick, lmEthFree, lmWethFree, lmEthTotal),
_snapshotPositions(fLiq, fLo, fHi, fEthValue, aLiq, aLo, aHi, aEthValue, dLiq, dLo, dHi, dEthValue),
_snapshotFooter(
vwapX96, vwapTick,
outstandingSupply, totalSupply,
anchorShare, capitalInefficiency, anchorWidth, discoveryDepth,
advEth, advKrk,
recenterIsUp, hasRecentered
)
);
}
function _snapshotHeader(
uint256 seq,
int24 tick,
uint256 lmEthFree,
uint256 lmWethFree,
uint256 lmEthTotal
)
internal
pure
returns (string memory)
{
return string.concat(
'{"seq":',
seq.str(),
',"tick":',
int256(tick).istr(),
',"lm_eth_free":"',
lmEthFree.str(),
'","lm_weth_free":"',
lmWethFree.str(),
'","lm_eth_total":"',
lmEthTotal.str(),
'"'
);
}
function _snapshotPositions(
uint128 fLiq,
int24 fLo,
int24 fHi,
uint256 fEthValue,
uint128 aLiq,
int24 aLo,
int24 aHi,
uint256 aEthValue,
uint128 dLiq,
int24 dLo,
int24 dHi,
uint256 dEthValue
)
internal
pure
returns (string memory)
{
return string.concat(
',"positions":{"floor":{"liquidity":"',
uint256(fLiq).str(),
'","tickLower":',
int256(fLo).istr(),
',"tickUpper":',
int256(fHi).istr(),
',"ethValue":"',
fEthValue.str(),
'"},"anchor":{"liquidity":"',
uint256(aLiq).str(),
'","tickLower":',
int256(aLo).istr(),
',"tickUpper":',
int256(aHi).istr(),
',"ethValue":"',
aEthValue.str(),
'"},"discovery":{"liquidity":"',
uint256(dLiq).str(),
'","tickLower":',
int256(dLo).istr(),
',"tickUpper":',
int256(dHi).istr(),
',"ethValue":"',
dEthValue.str(),
'"}}' // close discovery{} then positions{}; root object is closed by _snapshotFooter
);
}
function _snapshotFooter(
uint256 vwapX96,
int24 vwapTick,
uint256 outstandingSupply,
uint256 totalSupply,
uint256 anchorShare,
uint256 capitalInefficiency,
uint24 anchorWidth,
uint256 discoveryDepth,
uint256 advEth,
uint256 advKrk,
bool recenterIsUp,
bool hasRecentered
)
internal
pure
returns (string memory)
{
return string.concat(
',"vwap_x96":"',
vwapX96.str(),
'","vwap_tick":',
int256(vwapTick).istr(),
',"outstanding_supply":"',
outstandingSupply.str(),
'","total_supply":"',
totalSupply.str(),
'","optimizer_output":{"anchorShare":"',
anchorShare.str(),
'","capitalInefficiency":"',
capitalInefficiency.str(),
'","anchorWidth":',
uint256(anchorWidth).str(),
',"discoveryWidth":"',
discoveryDepth.str(),
'"},"adversary_eth":"',
advEth.str(),
'","adversary_krk":"',
advKrk.str(),
// Emit null before the first recenter so downstream parsers can distinguish
// "no recenter yet" from "last recenter moved price down" (false).
'","recenter_is_up":',
hasRecentered ? (recenterIsUp ? "true" : "false") : "null",
"}"
);
}
// Math helpers
/**
* @notice Compute the total ETH-equivalent value of a liquidity position at the current pool price.
* @dev Uses LiquidityAmounts.getAmountsForLiquidity which handles all three cases:
* fully below range (all token0), fully above range (all token1), and in-range (split).
* Both the ETH component and the KRK component (converted to ETH at current sqrtPriceX96)
* are summed so that lm_eth_total accurately reflects TVL regardless of price range.
*
* KRKETH conversion:
* If token0=WETH: price = KRK/WETH = sqrtP^2/2^192
* krkInEth = krk * 2^192 / sqrtP^2 = mulDiv(mulDiv(krk, 2^96, sqrtP), 2^96, sqrtP)
* If token0=KRK: price = WETH/KRK = sqrtP^2/2^192
* krkInEth = krk * sqrtP^2 / 2^192 = mulDiv(mulDiv(krk, sqrtP, 2^96), sqrtP, 2^96)
*/
function _positionEthValue(
uint160 sqrtPriceX96,
int24 tickLower,
int24 tickUpper,
uint128 liquidity
)
internal
view
returns (uint256)
{
if (liquidity == 0) return 0;
uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower);
uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper);
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAX96, sqrtRatioBX96, liquidity);
uint256 ethAmount = token0isWeth ? amount0 : amount1;
uint256 krkAmount = token0isWeth ? amount1 : amount0;
uint256 krkInEth = 0;
if (krkAmount > 0 && sqrtPriceX96 > 0) {
if (token0isWeth) {
// token0=WETH, token1=KRK: 1 KRK = 2^192 / sqrtP^2 WETH
krkInEth = FullMath.mulDiv(
FullMath.mulDiv(krkAmount, 1 << 96, sqrtPriceX96),
1 << 96,
sqrtPriceX96
);
} else {
// token0=KRK, token1=WETH: 1 KRK = sqrtP^2 / 2^192 WETH
krkInEth = FullMath.mulDiv(
FullMath.mulDiv(krkAmount, sqrtPriceX96, 1 << 96),
sqrtPriceX96,
1 << 96
);
}
}
return ethAmount + krkInEth;
}
/**
* @notice Convert a VWAP X96 value to a pool tick.
* @dev VWAP is stored as price * 2^96 (Q96 format) by _priceAtTick in UniswapMath.
* To recover the tick: sqrt(vwapX96 / 2^96) = sqrtPrice, then getTickAtSqrtRatio.
* Shift right by 32 converts Q96 Q64 (ABDK 64x64 format).
*
* Overflow guard: int128(int256(vwapX96 >> 32)) wraps to a negative value when
* vwapX96 > 2^159 (extremely high price ratios outside realistic KRK/WETH ranges).
* The `priceRatioX64 <= 0` check catches this and returns tick=0 rather than reverting,
* so snapshots remain valid callers should treat vwap_tick=0 as "VWAP unavailable"
* when vwap_x96 is non-zero. Additionally, ABDKMath64x64.sqrt(priceRatioX64) << 32
* could overflow int128 before the uint160 cast for pathologically large prices, but
* this is unreachable for any token pair with price < 2^32 (covers all practical cases).
*
* @param vwapX96 The VWAP in Q96 format (as returned by LM.getVWAP()).
* @return The pool tick corresponding to the VWAP price, or 0 if vwapX96 is out of range.
*/
function _computeVwapTick(uint256 vwapX96) internal pure returns (int24) {
if (vwapX96 == 0) return 0;
int128 priceRatioX64 = int128(int256(vwapX96 >> 32));
if (priceRatioX64 <= 0) return 0; // vwapX96 > 2^159: out of representable range, report 0
uint160 sqrtPriceX96_ = uint160(int160(ABDKMath64x64.sqrt(priceRatioX64) << 32));
return TickMath.getTickAtSqrtRatio(sqrtPriceX96_);
}
// Utility
function _eq(string memory a, string memory b) internal pure returns (bool) {
return keccak256(bytes(a)) == keccak256(bytes(b));
}
function _deploymentsPath() internal view returns (string memory) {
try vm.envString("DEPLOYMENTS_FILE") returns (string memory path) {
if (bytes(path).length > 0) return path;
} catch { }
return "deployments-local.json";
}
}

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"
@ -700,6 +704,42 @@ log "Extracting strategy findings from agent output..."
extract_memory "$STREAM_LOG"
log " lm_eth_after = $LM_ETH_AFTER wei"
# ── 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 ==="