harb/scripts/harb-evaluator/red-team.sh

779 lines
31 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# red-team.sh — Adversarial floor-attack agent runner.
#
# Spawns a Claude sub-agent with tools and a goal: make ethPerToken() decrease.
# The agent iterates freely — snapshot → strategy → check floor → revert → repeat.
#
# Usage: red-team.sh
#
# Exit codes:
# 0 floor held (no confirmed decrease)
# 1 floor broken (agent found a strategy that decreased ethPerToken)
# 2 infra error (stack not running, missing dependency, etc.)
#
# Environment overrides:
# CLAUDE_TIMEOUT seconds for the agent run (default: 7200)
# RPC_URL Anvil RPC endpoint (default: http://localhost:8545)
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="$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 ─────────────────────────────────────────────────────────────
# Account 8 — adversary (10k ETH, 0 KRK)
ADV_PK=0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97
# Account 2 — recenter caller (recenter() is permissionless; any account can call it)
RECENTER_PK=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
# ── Infrastructure constants ───────────────────────────────────────────────────
WETH=0x4200000000000000000000000000000000000006
SWAP_ROUTER=0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4
V3_FACTORY=0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24
NPM=0x27F971cb582BF9E50F397e4d29a5C7A34f11faA2
POOL_FEE=10000
# ── Logging helpers ────────────────────────────────────────────────────────────
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 "$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"
# ── 1. Fresh stack — tear down, rebuild, wait for bootstrap ────────────────────
log "Rebuilding fresh stack ..."
cd "$REPO_ROOT"
# Free RAM: drop caches
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' 2>/dev/null || true
# Tear down completely (volumes too — clean anvil state)
sudo docker compose down -v >/dev/null 2>&1 || true
sleep 3
# Bring up
sudo docker compose up -d >/dev/null 2>&1 \
|| die "docker compose up -d failed"
# Wait for bootstrap to complete (max 120s)
log "Waiting for bootstrap ..."
for i in $(seq 1 40); do
if sudo docker logs harb-bootstrap-1 2>&1 | grep -q "Bootstrap complete"; then
log " Bootstrap complete (${i}x3s)"
break
fi
if [[ $i -eq 40 ]]; then
die "Bootstrap did not complete within 120s"
fi
sleep 3
done
# Verify Anvil responds
"$CAST" chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Anvil not accessible at $RPC_URL after stack start"
# ── 2. Read contract addresses ─────────────────────────────────────────────────
[[ -f "$DEPLOYMENTS" ]] || die "deployments-local.json not found at $DEPLOYMENTS (bootstrap not complete)"
KRK=$(jq -r '.contracts.Kraiken' "$DEPLOYMENTS")
STAKE=$(jq -r '.contracts.Stake' "$DEPLOYMENTS")
LM=$(jq -r '.contracts.LiquidityManager' "$DEPLOYMENTS")
OPT=$(jq -r '.contracts.OptimizerProxy' "$DEPLOYMENTS")
for var in KRK STAKE LM OPT; do
val="${!var}"
[[ -n "$val" && "$val" != "null" ]] \
|| die "$var address missing from deployments-local.json — was bootstrap successful?"
done
log " KRK: $KRK"
log " STAKE: $STAKE"
log " LM: $LM"
log " OPT: $OPT"
# Derive Anvil account addresses from their private keys
ADV_ADDR=$("$CAST" wallet address --private-key "$ADV_PK")
RECENTER_ADDR=$("$CAST" wallet address --private-key "$RECENTER_PK")
log " Adversary: $ADV_ADDR (account 8)"
log " Recenter: $RECENTER_ADDR (account 2)"
# Get Uniswap V3 Pool address
POOL=$("$CAST" call "$V3_FACTORY" "getPool(address,address,uint24)(address)" \
"$WETH" "$KRK" "$POOL_FEE" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
log " Pool: $POOL"
# ── 3a. Set feeDestination to LM itself (fees accrue as liquidity) ─────────────
# recenter() is now permissionless — no setRecenterAccess() call needed.
# setFeeDestination allows repeated EOA sets; setting to a contract locks it permanently.
# The deployer (Anvil account 0) deployed LiquidityManager and may call setFeeDestination again.
# DEPLOYER_PK is Anvil's deterministic account-0 key — valid ONLY against a local ephemeral
# Anvil instance. Never run this script against a non-ephemeral or shared-state chain.
DEPLOYER_PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
log "Setting feeDestination to LM ($LM) ..."
"$CAST" send --rpc-url "$RPC_URL" --private-key "$DEPLOYER_PK" \
"$LM" "setFeeDestination(address)" "$LM" >/dev/null 2>&1 \
|| die "setFeeDestination($LM) failed"
VERIFY=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
log " feeDestination set to: $VERIFY"
[[ "${VERIFY,,}" == "${LM,,}" ]] || die "feeDestination verification failed: expected $LM, got $VERIFY"
# ── 3b. Fund LM with 1000 ETH and deploy into positions via recenter ───────────
# Send ETH as WETH (LM uses WETH internally), then recenter to deploy into positions.
# Without recenter, the ETH sits idle and the first recenter mints massive KRK.
log "Funding LM with 1000 ETH ..."
# Wrap to WETH and transfer to LM
"$CAST" send "$WETH" "deposit()" --value 1000ether \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to wrap ETH"
"$CAST" send "$WETH" "transfer(address,uint256)" "$LM" 1000000000000000000000 \
--private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| die "Failed to transfer WETH to LM"
# Recenter to deploy the new WETH into positions (establishes realistic baseline)
log "Recentering to deploy funded WETH into positions ..."
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \
|| log " WARNING: initial recenter failed (may need amplitude — mining blocks)"
# Mine blocks and retry if needed
for _i in $(seq 1 3); do
for _b in $(seq 1 50); do
"$CAST" rpc evm_mine --rpc-url "$RPC_URL" >/dev/null 2>&1
done
"$CAST" send "$LM" "recenter()" \
--private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 && break
done
LM_ETH=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g')
log " LM after recenter: ETH=$LM_ETH WETH=$LM_WETH"
# ── 4. Take Anvil snapshot (clean baseline) ────────────────────────────────────
log "Taking Anvil snapshot..."
SNAP=$("$CAST" rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"')
log " Snapshot ID: $SNAP"
# Revert to the baseline snapshot on exit so subsequent runs start clean.
CLAUDE_PID=""
cleanup() {
local rc=$?
if [[ -n "${CLAUDE_PID:-}" ]]; then
kill "$CLAUDE_PID" 2>/dev/null || true
fi
if [[ -n "${SNAP:-}" ]]; then
"$CAST" rpc anvil_revert "$SNAP" --rpc-url "$RPC_URL" >/dev/null 2>&1 || true
fi
exit $rc
}
trap cleanup EXIT INT TERM
# ── Helper: compute total ETH controlled by LM ────────────────────────────────
# Total = free ETH + free WETH + ETH locked in all 3 Uni V3 positions
# This is the real metric: "can the adversary extract ETH from the protocol?"
# Uses a forge script with exact Uni V3 integer math (LiquidityAmounts + TickMath)
# instead of multiple cast calls + Python float approximation.
compute_lm_total_eth() {
local output result
output=$(LM="$LM" WETH="$WETH" POOL="$POOL" \
/home/debian/.foundry/bin/forge script script/LmTotalEth.s.sol \
--rpc-url "$RPC_URL" --root "$REPO_ROOT/onchain" --no-color 2>&1)
# forge script prints "== Logs ==" then " <value>" — extract the number
result=$(echo "$output" | awk '/^== Logs ==/{getline; gsub(/^[[:space:]]+/,""); print; exit}')
[[ -n "$result" && "$result" =~ ^[0-9]+$ ]] || die "Failed to read LM total ETH (forge output: $output)"
echo "$result"
}
# ── Helper: extract strategy findings from stream-json and append to memory ────
extract_memory() {
local stream_file="$1"
local run_num memory_file="$MEMORY_FILE"
# Determine run number: one entry per line in JSONL, so next run = line_count + 1
if [[ -f "$memory_file" ]]; then
run_num=$(wc -l < "$memory_file")
run_num=$((run_num + 1))
else
run_num=1
fi
python3 - "$stream_file" "$memory_file" "$run_num" "$LM_ETH_BEFORE" <<'PYEOF'
import json, sys, re
from datetime import datetime, timezone
stream_file = sys.argv[1]
memory_file = sys.argv[2]
run_num = int(sys.argv[3])
try:
lm_eth_before = int(sys.argv[4])
except (ValueError, IndexError):
print(" extract_memory: invalid lm_eth_before value, skipping", file=sys.stderr)
sys.exit(0)
texts = []
with open(stream_file) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if obj.get("type") == "assistant":
for block in obj.get("message", {}).get("content", []):
if block.get("type") == "text":
texts.append(block["text"])
except:
pass
# Parse strategies from agent text
strategies = []
current = None
for text in texts:
# Detect strategy headers: matches "## Strategy 1: name" and "STRATEGY 1: name"
strat_match = re.search(r"(?:##\s*)?[Ss][Tt][Rr][Aa][Tt][Ee][Gg][Yy]\s*\d+[^:]*:\s*(.+)", text)
if strat_match:
if current:
strategies.append(current)
current = {
"strategy": strat_match.group(1).strip(),
"steps": "",
"lm_eth_after": None,
"insight": ""
}
if current:
# Capture floor readings — take the last match in the block (most recent value)
floor_matches = list(re.finditer(r"(?:floor|ethPerToken|lm.?eth)[^\d]*?(\d{4,})\s*(?:wei)?", text, re.IGNORECASE))
if floor_matches:
current["lm_eth_after"] = int(floor_matches[-1].group(1))
# Capture insights
for pattern in [r"[Kk]ey [Ii]nsight:\s*(.+)", r"[Ii]nsight:\s*(.+)", r"(?:discovered|learned|realized)\s+(?:that\s+)?(.+)"]:
insight_match = re.search(pattern, text)
if insight_match and len(insight_match.group(1)) > 20:
current["insight"] = insight_match.group(1).strip()[:300]
# Capture step summaries
if any(word in text.lower() for word in ["wrap", "buy", "sell", "stake", "recenter", "mint", "approve"]):
if len(text) < 200:
current["steps"] += text.strip() + "; "
if current:
strategies.append(current)
# Write to memory file
ts = datetime.now(timezone.utc).isoformat()
with open(memory_file, "a") as f:
for s in strategies:
fa = s["lm_eth_after"] if s.get("lm_eth_after") is not None else lm_eth_before
delta_bps = round((fa - lm_eth_before) * 10000 / lm_eth_before) if lm_eth_before else 0
if fa < lm_eth_before:
result = "DECREASED"
elif fa > lm_eth_before:
result = "INCREASED"
else:
result = "HELD"
entry = {
"run": run_num,
"ts": ts,
"strategy": s["strategy"][:100],
"steps": s["steps"][:300].rstrip("; "),
"lm_eth_before": lm_eth_before,
"lm_eth_after": fa,
"delta_bps": delta_bps,
"result": result,
"insight": s["insight"][:300]
}
f.write(json.dumps(entry) + "\n")
print(f" Recorded: {entry['strategy']} → {result} ({delta_bps:+d} bps)")
if not strategies:
print(" No strategies detected in stream output")
# Trim memory file: keep 10 most recent + all DECREASED entries (cap at 50)
with open(memory_file) as f:
all_entries = [json.loads(l) for l in f if l.strip()]
if len(all_entries) > 50:
# Keep all DECREASED entries + 10 most recent; deduplicate preserving order
trimmed = [e for e in all_entries if e.get("result") == "DECREASED"] + all_entries[-10:]
seen = set()
deduped = []
for e in trimmed:
key = (e.get("run"), e.get("ts"), e.get("strategy"))
if key not in seen:
seen.add(key)
deduped.append(e)
with open(memory_file, "w") as f:
for e in deduped:
f.write(json.dumps(e) + "\n")
print(f" Trimmed memory to {len(deduped)} entries")
PYEOF
}
# ── 5. Read lm_eth_before ───────────────────────────────────────────────────────
log "Reading floor before agent run..."
LM_ETH_BEFORE=$(compute_lm_total_eth)
log " lm_eth_before = $LM_ETH_BEFORE wei"
# ── 6. Build agent prompt ──────────────────────────────────────────────────────
# Build Previous Findings section from memory file
MEMORY_SECTION=""
if [[ -f "$MEMORY_FILE" && -s "$MEMORY_FILE" ]]; then
MEMORY_SECTION=$(python3 - "$MEMORY_FILE" <<'PYEOF'
import json, sys
entries = []
with open(sys.argv[1]) as f:
for line in f:
line = line.strip()
if line:
entries.append(json.loads(line))
if not entries:
sys.exit(0)
print('## Previous Findings (from earlier runs)')
print()
print('DO NOT repeat strategies marked HELD or INCREASED. Build on the insights.')
print('Try NEW combinations not yet attempted. Combine tools creatively.')
print()
for e in entries:
r = e.get('result', '?')
emoji = '❌' if r == 'DECREASED' else '⬆️' if r == 'INCREASED' else '➡️'
print(f"### Run {e.get('run','?')}: {e.get('strategy','?')} {emoji} {r}")
print(f"Steps: {e.get('steps','?')}")
print(f"Delta: {e.get('delta_bps',0)} bps")
if e.get('insight'):
print(f"**Insight:** {e['insight']}")
print()
PYEOF
)
fi
PROMPT=$(cat <<PROMPT_EOF
# Red-team mission: extract ETH from the KRAIKEN LiquidityManager
## Goal
**Extract ETH from the LiquidityManager.** Make the LM end up with less total ETH than it started with.
Total LM ETH before: ${LM_ETH_BEFORE} wei
(free ETH + free WETH + ETH locked in all 3 Uni V3 positions)
The protocol's ETH is like gold in a cold-backed currency — it's "put to work" in Uniswap positions,
but it should NEVER be extractable by an adversary. Your job is to prove otherwise.
The metric is simple: if LM total ETH goes down, you win.
---
## Contract addresses (local Anvil)
| Contract | Address |
|--------------------|---------|
| Kraiken (KRK) | ${KRK} |
| Stake | ${STAKE} |
| LiquidityManager | ${LM} |
| OptimizerProxy | ${OPT} |
| Pool (WETH/KRK 1%) | ${POOL} |
| NonfungiblePosManager (NPM) | ${NPM} |
| WETH | ${WETH} |
| SwapRouter02 | ${SWAP_ROUTER} |
RPC: http://localhost:8545
CAST binary: /home/debian/.foundry/bin/cast
---
## Your accounts
### Adversary — Anvil account 8 (your main account)
- Address: ${ADV_ADDR}
- Private key: ${ADV_PK}
- Balance: ~9000 ETH (10k minus 1000 ETH used to fund LM), 0 KRK
### Recenter caller — Anvil account 2
- Address: ${RECENTER_ADDR}
- Private key: ${RECENTER_PK}
- Can call recenter() (permissionless — 60s cooldown + TWAP check enforced)
---
## Protocol mechanics
### ethPerToken (the floor)
\`\`\`
ethPerToken = (LM_native_ETH + LM_WETH) * 1e18 / adjusted_supply
adjusted_supply = KRK.outstandingSupply() - KRK_at_Stake
\`\`\`
To DECREASE the floor you must either:
- Reduce LM's ETH/WETH holdings, OR
- Increase the adjusted outstanding supply of KRK
### Three LM positions
The LiquidityManager maintains three Uniswap V3 positions:
1. **ANCHOR** — straddles the current price; provides two-sided liquidity
2. **DISCOVERY** — above current price; captures upside momentum
3. **FLOOR** — a floor bid: ETH in, KRK out. Backing the floor price.
### recenter()
Calling \`LiquidityManager.recenter()\` removes all three positions, mints or burns KRK
to rebalance, then re-deploys positions at the current price. It:
- Can mint NEW KRK (increasing supply → decreasing floor)
- Can burn KRK (decreasing supply → increasing floor)
- Moves ETH between positions
recenter() is permissionless — any account can call it (subject to 60s cooldown and TWAP check).
### Staking
\`Stake.snatch(assets, receiver, taxRateIndex, positionsToSnatch)\`
- taxRateIndex: 029 (index into the 30-element TAX_RATES array — not a raw percentage)
- KRK staked is held by the Stake contract (excluded from adjusted_supply)
- KRK in Stake does NOT count against the floor denominator
### outstandingSupply() vs totalSupply()
\`KRK.outstandingSupply() = totalSupply() - balanceOf(liquidityManager)\`
LM-held KRK (in pool positions) is excluded from outstandingSupply.
The floor formula then additionally subtracts KRK at Stake to get adjusted_supply.
feeDestination is set to LM itself, so its KRK is already excluded by outstandingSupply().
---
## Cast command patterns
### Check total LM ETH (run after each strategy)
Measures free ETH + free WETH + ETH locked in all 3 Uni V3 positions.
\`\`\`bash
CAST=/home/debian/.foundry/bin/cast
LM_ETH=\$(\$CAST balance ${LM} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
LM_WETH=\$(\$CAST call ${WETH} "balanceOf(address)(uint256)" ${LM} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g')
SLOT0=\$(\$CAST call ${POOL} "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url http://localhost:8545)
CUR_TICK=\$(echo "\$SLOT0" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TOKEN0_IS_WETH=\$(python3 -c "print(1 if '${WETH}'.lower() < '${KRK}'.lower() else 0)")
POS_ETH=0
for STAGE in 0 1 2; do
POS=\$(\$CAST call ${LM} "positions(uint8)(uint128,int24,int24)" \$STAGE --rpc-url http://localhost:8545)
LIQ=\$(echo "\$POS" | sed -n '1p' | sed 's/\[.*//;s/[[:space:]]//g')
TL=\$(echo "\$POS" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g')
TU=\$(echo "\$POS" | sed -n '3p' | sed 's/\[.*//;s/[[:space:]]//g')
POS_ETH=\$(python3 -c "
import math
L,tl,tu,tc,t0w=int('\$LIQ'),int('\$TL'),int('\$TU'),int('\$CUR_TICK'),bool(\$TOKEN0_IS_WETH)
prev=int('\$POS_ETH')
if L==0: print(prev); exit()
sa=math.sqrt(1.0001**tl); sb=math.sqrt(1.0001**tu); sc=math.sqrt(1.0001**tc)
if t0w:
e=L*(1/sa-1/sb) if tc<tl else (0 if tc>=tu else L*(1/sc-1/sb))
else:
e=L*(sb-sa) if tc>=tu else (0 if tc<tl else L*(sc-sa))
print(prev+int(e))
")
done
TOTAL=\$(python3 -c "print(int('\$LM_ETH')+int('\$LM_WETH')+int('\$POS_ETH'))")
echo "Total LM ETH: \$TOTAL wei (free: \$LM_ETH + \$LM_WETH, positions: \$POS_ETH)"
echo "Started with: ${LM_ETH_BEFORE} wei"
python3 -c "b=${LM_ETH_BEFORE}; a=int('\$TOTAL'); d=b-a; print(f'Delta: {d} wei ({d*100//b if b else 0}% extracted)' if d>0 else f'Delta: {d} wei (LM gained ETH)')"
\`\`\`
### Wrap ETH to WETH
\`\`\`bash
/home/debian/.foundry/bin/cast send ${WETH} "deposit()" --value 100ether \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Approve token spend
\`\`\`bash
/home/debian/.foundry/bin/cast send <TOKEN> "approve(address,uint256)" <SPENDER> \
115792089237316195423570985008687907853269984665640564039457584007913129639935 \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Buy KRK (WETH → KRK via SwapRouter)
\`\`\`bash
# Must wrap ETH and approve WETH first
/home/debian/.foundry/bin/cast send ${SWAP_ROUTER} \
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \
"(${WETH},${KRK},${POOL_FEE},${ADV_ADDR},<WETH_AMOUNT>,0,0)" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Sell KRK (KRK → WETH via SwapRouter)
\`\`\`bash
# Must approve KRK first
/home/debian/.foundry/bin/cast send ${SWAP_ROUTER} \
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \
"(${KRK},${WETH},${POOL_FEE},${ADV_ADDR},<KRK_AMOUNT>,0,0)" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Stake KRK (snatch with no snatching)
\`\`\`bash
# Approve KRK to Stake first
/home/debian/.foundry/bin/cast send ${STAKE} \
"snatch(uint256,address,uint32,uint256[])" \
<KRK_AMOUNT> ${ADV_ADDR} 0 "[]" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Unstake KRK
\`\`\`bash
/home/debian/.foundry/bin/cast send ${STAKE} \
"exitPosition(uint256)" <POSITION_ID> \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Trigger recenter (account 2 only)
\`\`\`bash
/home/debian/.foundry/bin/cast send ${LM} "recenter()" \
--private-key ${RECENTER_PK} --rpc-url http://localhost:8545
\`\`\`
### Read KRK balance
\`\`\`bash
/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" ${ADV_ADDR} \
--rpc-url http://localhost:8545
\`\`\`
### Read ETH balance
\`\`\`bash
/home/debian/.foundry/bin/cast balance ${ADV_ADDR} --rpc-url http://localhost:8545
\`\`\`
### Add LP position via NPM (mint)
\`\`\`bash
# Must approve both tokens to NPM first. tickLower/tickUpper must be multiples of 200 (pool tickSpacing).
/home/debian/.foundry/bin/cast send ${NPM} \
"mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \
"(${WETH},${KRK},${POOL_FEE},<TICK_LOWER>,<TICK_UPPER>,<AMOUNT0>,<AMOUNT1>,0,0,${ADV_ADDR},<DEADLINE>)" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Remove LP position via NPM (decreaseLiquidity then collect)
\`\`\`bash
/home/debian/.foundry/bin/cast send ${NPM} \
"decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))" \
"(<TOKEN_ID>,<LIQUIDITY>,0,0,<DEADLINE>)" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
/home/debian/.foundry/bin/cast send ${NPM} \
"collect((uint256,address,uint128,uint128))" \
"(<TOKEN_ID>,${ADV_ADDR},340282366920938463463374607431768211455,340282366920938463463374607431768211455)" \
--private-key ${ADV_PK} --rpc-url http://localhost:8545
\`\`\`
### Mine a block
\`\`\`bash
/home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545
\`\`\`
### Snapshot and revert (for resetting between strategies)
\`\`\`bash
# Take snapshot (returns ID — save it):
SNAP=\$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://localhost:8545 | tr -d '"')
# Revert to snapshot (one-shot — take a new snapshot immediately after):
/home/debian/.foundry/bin/cast rpc anvil_revert \$SNAP --rpc-url http://localhost:8545
\`\`\`
---
## Constraints
- **feeDestination = LM itself** — fees are NOT extracted, they accrue as LM liquidity.
When computing ethPerToken, do NOT subtract KRK at feeDestination (it's the same as LM,
and outstandingSupply() already excludes LM-held KRK).
- **LM has ~1000 ETH reserve** — proportional to your 10,000 ETH (10:1 ratio). This is a
realistic attack scenario, not an empty vault.
- **You MUST NOT call anvil_reset, anvil_setCode, or anvil_setStorageAt.** These are infra
cheats that invalidate the test. Use only swap/stake/LP/recenter protocol operations.
## Rules
1. You have ~9000 ETH (after funding LM with 1000 ETH). Start by wrapping some if you need WETH for swaps.
2. Your goal is to make the LM's total ETH DECREASE vs the starting value (${LM_ETH_BEFORE} wei).
3. Try at least 3 distinct strategies. After each attempt:
a. Run the total LM ETH check command above.
b. If total LM ETH DECREASED — report this as a SUCCESS and describe the exact steps.
c. If LM ETH held or INCREASED — revert to the snapshot and try a new strategy.
Remember: \`anvil_revert\` is one-shot. Take a new snapshot immediately after reverting.
4. You may chain multiple actions in one strategy (e.g. large buy → recenter → large sell).
5. Be methodical. Report every strategy tried even if it failed.
6. If Previous Findings are provided, DO NOT repeat those strategies. Use their insights to design new approaches.
7. Prioritize untried COMBINATIONS: staking + LP, staking + recenter timing, LP + multi-step swaps, etc.
8. Start executing immediately. No lengthy planning — act, measure, iterate.
---
${MEMORY_SECTION}
## Final report format
After trying all strategies, output a clearly structured report:
\`\`\`
=== RED-TEAM REPORT ===
lm_eth_before: <value> wei (total: free + positions)
STRATEGY 1: <name>
Steps: <what you did>
lm_eth_after: <value> wei
Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED
STRATEGY 2: ...
...
=== CONCLUSION ===
ETH extracted: YES / NO
Winning strategy: <describe if YES, else "None">
lm_eth_before: ${LM_ETH_BEFORE} wei
lm_eth_after: <final value> wei
\`\`\`
PROMPT_EOF
)
# ── 7. Create output directory and run the agent ───────────────────────────────
mkdir -p "$REPORT_DIR"
log "Spawning Claude red-team agent (timeout: ${CLAUDE_TIMEOUT}s)..."
log " Report will be written to: $REPORT"
set +e
# Note: --verbose is required by the claude CLI when --output-format stream-json is used;
# omitting it causes the CLI to exit with an error, producing an empty stream log.
timeout "$CLAUDE_TIMEOUT" claude -p --dangerously-skip-permissions \
--verbose --output-format stream-json \
"$PROMPT" >"$STREAM_LOG" 2>&1 &
CLAUDE_PID=$!
wait "$CLAUDE_PID"
AGENT_EXIT=$?
CLAUDE_PID=""
set -e
if [[ $AGENT_EXIT -ne 0 ]]; then
log "WARNING: claude exited with code $AGENT_EXIT — see $STREAM_LOG for details"
fi
# Extract readable text from stream-json for the report
python3 - "$STREAM_LOG" >"$REPORT" <<'PYEOF'
import json, sys
with open(sys.argv[1]) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if obj.get("type") == "assistant":
for block in obj.get("message", {}).get("content", []):
if block.get("type") == "text":
print(block["text"], end="")
except:
pass
PYEOF
# If the agent crashed and produced no readable output, treat as an infra error
# rather than silently reporting ETH SAFE (a false pass).
if [[ $AGENT_EXIT -ne 0 && ! -s "$REPORT" ]]; then
die "claude agent failed (exit $AGENT_EXIT) with no readable output — see $STREAM_LOG"
fi
# ── 8. Read lm_eth_after ────────────────────────────────────────────────────────
log "Reading floor after agent run..."
LM_ETH_AFTER=$(compute_lm_total_eth)
# ── 8a. Extract and persist strategy findings ──────────────────────────────────
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 ==="
log ""
log " lm_eth_before : $LM_ETH_BEFORE wei"
log " lm_eth_after : $LM_ETH_AFTER wei"
log ""
BROKE=false
if python3 -c "import sys; sys.exit(0 if int('${LM_ETH_AFTER:-0}') < int('${LM_ETH_BEFORE:-0}') else 1)"; then
BROKE=true
fi
if [[ "$BROKE" == "true" ]]; then
DELTA=$(python3 -c "print(int('${LM_ETH_BEFORE:-0}') - int('${LM_ETH_AFTER:-0}'))")
log " RESULT: ETH EXTRACTED ❌"
log " Decrease: $DELTA wei"
log ""
log " See $REPORT for the winning strategy."
log ""
# Append a machine-readable summary to the report
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : -$DELTA
verdict : ETH_EXTRACTED
SUMMARY_EOF
exit 1
else
log " RESULT: ETH SAFE ✅"
log ""
log " See $REPORT for strategies attempted."
log ""
cat >>"$REPORT" <<SUMMARY_EOF
=== RUNNER SUMMARY ===
lm_eth_before : $LM_ETH_BEFORE
lm_eth_after : $LM_ETH_AFTER
delta : 0 (or increase)
verdict : ETH_SAFE
SUMMARY_EOF
exit 0
fi