#!/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 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" DEPLOYMENTS="$REPO_ROOT/onchain/deployments-local.json" # ── Anvil accounts ───────────────────────────────────────────────────────────── # Account 8 — adversary (10k ETH, 0 KRK) ADV_PK=0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97 # Account 2 — recenter caller (granted recenterAccess by bootstrap) 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 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. Verify stack is running ───────────────────────────────────────────────── log "Verifying Anvil is accessible at $RPC_URL ..." "$CAST" chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1 \ || die "Anvil not accessible at $RPC_URL — run: ./scripts/dev.sh 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") log " Pool: $POOL" # ── 3. Grant recenterAccess to account 2 ────────────────────────────────────── # Done BEFORE the snapshot so every revert restores account 2's access. # LM.recenterAccess is a single address slot — replace it with account 2. # Only the feeDestination is authorised to call setRecenterAccess(). log "Granting recenterAccess to account 2 ($RECENTER_ADDR) ..." FEE_DEST=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL") \ || die "Failed to read feeDestination() from LM" FEE_DEST=$(echo "$FEE_DEST" | tr -d '[:space:]') "$CAST" rpc --rpc-url "$RPC_URL" anvil_impersonateAccount "$FEE_DEST" \ || die "anvil_impersonateAccount $FEE_DEST failed" "$CAST" send --rpc-url "$RPC_URL" --from "$FEE_DEST" --unlocked \ "$LM" "setRecenterAccess(address)" "$RECENTER_ADDR" >/dev/null 2>&1 \ || die "setRecenterAccess($RECENTER_ADDR) failed — check that feeDestination is correct" "$CAST" rpc --rpc-url "$RPC_URL" anvil_stopImpersonatingAccount "$FEE_DEST" \ || die "anvil_stopImpersonatingAccount $FEE_DEST failed" log " recenterAccess granted" # ── 4. Take Anvil snapshot (clean baseline, includes recenterAccess grant) ───── 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. cleanup() { local rc=$? 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 ethPerToken (mirrors floor.ts getEthPerToken) ────────────── # ethPerToken = (lm_native_eth + lm_weth) * 1e18 / adjusted_outstanding_supply # adjusted_supply = outstandingSupply() - KRK_at_feeDestination - KRK_at_stakingPool compute_eth_per_token() { local lm_eth lm_weth supply fee_bal stake_bal adj_supply lm_eth=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]') lm_weth=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" \ --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]') supply=$("$CAST" call "$KRK" "outstandingSupply()(uint256)" \ --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]') # Fee destination: read from contract (set at deploy time, may differ per fork) local fee_dest fee_dest=$("$CAST" call "$LM" "feeDestination()(address)" \ --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]') fee_bal=0 local zero="0x0000000000000000000000000000000000000000" if [[ "${fee_dest,,}" != "${zero,,}" ]]; then fee_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$fee_dest" \ --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0) fi # Staking pool: use the deployed Stake address (mirrors peripheryContracts()[1]) stake_bal=$("$CAST" call "$KRK" "balanceOf(address)(uint256)" "$STAKE" \ --rpc-url "$RPC_URL" 2>/dev/null | tr -d '[:space:]' || echo 0) python3 - <0 else 0, 'wei/token')" \`\`\` ### 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 "approve(address,uint256)" \ 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},,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},,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[])" \ ${ADV_ADDR} 0 "[]" \ --private-key ${ADV_PK} --rpc-url http://localhost:8545 \`\`\` ### Unstake KRK \`\`\`bash /home/debian/.foundry/bin/cast send ${STAKE} \ "exitPosition(uint256)" \ --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},,,,,0,0,${ADV_ADDR},)" \ --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))" \ "(,,0,0,)" \ --private-key ${ADV_PK} --rpc-url http://localhost:8545 /home/debian/.foundry/bin/cast send ${NPM} \ "collect((uint256,address,uint128,uint128))" \ "(,${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 \`\`\` --- ## Rules 1. You have 10 000 ETH. Start by wrapping some if you need WETH for swaps. 2. Your goal is to make ethPerToken DECREASE vs the starting value (${FLOOR_BEFORE} wei/token). 3. Try at least 3 distinct strategies. After each attempt: a. Run the floor-check command above to measure ethPerToken. b. If the floor DECREASED — report this as a SUCCESS and describe the exact steps. c. If the floor 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. --- ## Final report format After trying all strategies, output a clearly structured report: \`\`\` === RED-TEAM REPORT === floor_before: wei/token STRATEGY 1: Steps: floor_after: Result: INCREASED / HELD / DECREASED STRATEGY 2: ... ... === CONCLUSION === Floor broken: YES / NO Winning strategy: floor_before: ${FLOOR_BEFORE} wei/token floor_after: \`\`\` 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 timeout "$CLAUDE_TIMEOUT" claude -p --dangerously-skip-permissions \ "$PROMPT" >"$REPORT" 2>&1 AGENT_EXIT=$? set -e if [[ $AGENT_EXIT -ne 0 ]]; then log "WARNING: claude exited with code $AGENT_EXIT — see $REPORT for details" fi # ── 8. Read floor_after ──────────────────────────────────────────────────────── log "Reading floor after agent run..." FLOOR_AFTER=$(compute_eth_per_token) log " floor_after = $FLOOR_AFTER wei/token" # ── 9. Summarise results ─────────────────────────────────────────────────────── log "" log "=== RED-TEAM SUMMARY ===" log "" log " floor_before : $FLOOR_BEFORE wei/token" log " floor_after : $FLOOR_AFTER wei/token" log "" BROKE=false if python3 -c "import sys; sys.exit(0 if int('$FLOOR_AFTER') < int('$FLOOR_BEFORE') else 1)"; then BROKE=true fi if [[ "$BROKE" == "true" ]]; then DELTA=$(python3 -c "print($FLOOR_BEFORE - $FLOOR_AFTER)") log " RESULT: FLOOR BROKEN ❌" log " Decrease: $DELTA wei/token" log "" log " See $REPORT for the winning strategy." log "" # Append a machine-readable summary to the report cat >>"$REPORT" <>"$REPORT" <