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:
parent
08b9a3df30
commit
c8453f6a33
8 changed files with 1261 additions and 2 deletions
643
onchain/script/backtesting/AttackRunner.s.sol
Normal file
643
onchain/script/backtesting/AttackRunner.s.sol
Normal 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 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
153
onchain/script/backtesting/attacks/il-crystallization-80.jsonl
Normal file
153
onchain/script/backtesting/attacks/il-crystallization-80.jsonl
Normal 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"}
|
||||
60
onchain/script/backtesting/attacks/round-trip-safe.jsonl
Normal file
60
onchain/script/backtesting/attacks/round-trip-safe.jsonl
Normal 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"}
|
||||
14
onchain/script/backtesting/attacks/staking-safe.jsonl
Normal file
14
onchain/script/backtesting/attacks/staking-safe.jsonl
Normal 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"}
|
||||
298
scripts/harb-evaluator/export-attacks.py
Executable file
298
scripts/harb-evaluator/export-attacks.py
Executable 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()
|
||||
|
|
@ -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 ==="
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue