#!/usr/bin/env bash # ============================================================================= # fitness.sh — Push3 optimizer fitness scoring wrapper # # Pipeline: Push3 candidate → transpile → compile → deploy/upgrade → attack ×N → score # # Usage: # ./tools/push3-evolution/fitness.sh # # Output: # Single integer on stdout — total lm_eth_total across all attacks (wei). # # Exit codes: # 0 Success — score printed to stdout. # 1 Invalid candidate — Push3 program won't transpile, compile, or deploy. # 2 Infra error — Anvil unavailable, missing tool, bootstrap failure. # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" ONCHAIN_DIR="$REPO_ROOT/onchain" ATTACKS_DIR="$ONCHAIN_DIR/script/backtesting/attacks" RPC_URL="http://localhost:8545" # Standard Anvil test accounts (deterministic mnemonic) MNEMONIC="test test test test test test test test test test test junk" # Account 2 — recenter caller RECENTER_PK="0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" # Account 8 — adversary (used to fund LM with WETH) ADV_PK="0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97" # WETH address on the local Anvil Base fork WETH="0x4200000000000000000000000000000000000006" # ============================================================================= # Argument parsing # ============================================================================= if [ $# -ne 1 ]; then echo "Usage: $0 " >&2 exit 2 fi PUSH3_FILE="$1" if [ ! -f "$PUSH3_FILE" ]; then echo "Error: File not found: $PUSH3_FILE" >&2 exit 1 fi # Canonicalise so relative paths work after cwd changes. PUSH3_FILE="$(cd "$(dirname "$PUSH3_FILE")" && pwd)/$(basename "$PUSH3_FILE")" # ============================================================================= # Helpers # ============================================================================= log() { echo " [fitness] $*" >&2; } fail1() { echo " [invalid] $*" >&2; exit 1; } fail2() { echo " [infra] $*" >&2; exit 2; } # ============================================================================= # Tool check # ============================================================================= for _tool in forge cast anvil python3; do command -v "$_tool" &>/dev/null || fail2 "$_tool not found in PATH" done # ============================================================================= # Cleanup # ============================================================================= ANVIL_PID="" WORK_DIR="$(mktemp -d)" cleanup() { [ -n "$ANVIL_PID" ] && kill "$ANVIL_PID" 2>/dev/null || true rm -rf "$WORK_DIR" } trap cleanup EXIT # ============================================================================= # Step 0 — Start Anvil (if not already running) # ============================================================================= if cast chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1; then log "Anvil already running at $RPC_URL" else anvil --silent \ --mnemonic "$MNEMONIC" \ --port 8545 & ANVIL_PID=$! TRIES=0 until cast chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1; do TRIES=$((TRIES + 1)) [ $TRIES -gt 50 ] && fail2 "Anvil did not start within 50 attempts" sleep 0.2 done log "Anvil started (PID $ANVIL_PID)" fi # ============================================================================= # Steps 1–3 — Transpile → compile → deploy fresh stack → UUPS upgrade # # deploy-optimizer.sh handles the full pipeline. With no OPTIMIZER_PROXY set it # also runs DeployLocal.sol to produce the initial stack. # # Exit codes from deploy-optimizer.sh all map to exit 1 (invalid candidate) # because transpile / compile / round-trip failures are candidate issues. # ============================================================================= log "Running deploy-optimizer.sh (transpile → compile → deploy → upgrade)…" DEPLOY_LOG="$WORK_DIR/deploy.log" DEPLOY_EC=0 "$REPO_ROOT/tools/deploy-optimizer.sh" "$PUSH3_FILE" >"$DEPLOY_LOG" 2>&1 || DEPLOY_EC=$? if [ "$DEPLOY_EC" -ne 0 ]; then # Surface the deploy log so operators can diagnose candidate failures. cat "$DEPLOY_LOG" >&2 fail1 "deploy-optimizer.sh failed (exit $DEPLOY_EC)" fi log "Optimizer deployed and upgraded" # ============================================================================= # Step 4 — Read deployment addresses # # DeployLocal.sol writes to onchain/deployments-local.json; addresses are # deterministic for a fresh Anvil + standard mnemonic. # ============================================================================= DEPLOYMENTS="$ONCHAIN_DIR/deployments-local.json" [ -f "$DEPLOYMENTS" ] || fail2 "deployments-local.json not found — did DeployLocal.sol run?" LM_ADDR=$(python3 -c " import json d = json.load(open('$DEPLOYMENTS')) print(d['contracts']['LiquidityManager']) " 2>/dev/null) || fail2 "Failed to read LiquidityManager from deployments-local.json" [ -n "$LM_ADDR" ] || fail2 "LiquidityManager address is empty in deployments-local.json" log "LiquidityManager: $LM_ADDR" # ============================================================================= # Step 5 — Bootstrap LM state # # a. Grant recenterAccess to the standard Anvil account 2 (impersonate feeDestination). # b. Fund LM with 1000 WETH from the adversary account (account 8). # c. Call recenter() to deploy the capital into Uniswap positions so attacks # have something meaningful to work against. The LM needs at least some # TWAP history; mine blocks and retry until recenter succeeds. # ============================================================================= RECENTER_ADDR=$(cast wallet address --private-key "$RECENTER_PK") FEE_DEST=$(cast call "$LM_ADDR" "feeDestination()(address)" \ --rpc-url "$RPC_URL" 2>/dev/null | sed 's/\[.*//;s/[[:space:]]//g') \ || fail2 "Failed to read feeDestination() from LM" log "Granting recenterAccess to $RECENTER_ADDR (via feeDestination $FEE_DEST)" cast rpc --rpc-url "$RPC_URL" anvil_impersonateAccount "$FEE_DEST" >/dev/null cast send --rpc-url "$RPC_URL" --from "$FEE_DEST" --unlocked \ "$LM_ADDR" "setRecenterAccess(address)" "$RECENTER_ADDR" >/dev/null 2>&1 \ || fail2 "setRecenterAccess failed" cast rpc --rpc-url "$RPC_URL" anvil_stopImpersonatingAccount "$FEE_DEST" >/dev/null log "Funding LM with 1000 WETH" cast send "$WETH" "deposit()" --value 1000ether \ --private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \ || fail2 "Failed to wrap ETH to WETH" cast send "$WETH" "transfer(address,uint256)" "$LM_ADDR" 1000000000000000000000 \ --private-key "$ADV_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1 \ || fail2 "Failed to transfer WETH to LM" log "Initial recenter — deploying capital into positions" RECENTERED=false for _attempt in 1 2 3 4; do # Mine 50 blocks each attempt to accumulate TWAP history. for _b in $(seq 1 50); do cast rpc evm_mine --rpc-url "$RPC_URL" >/dev/null 2>&1 done if cast send "$LM_ADDR" "recenter()" \ --private-key "$RECENTER_PK" --rpc-url "$RPC_URL" >/dev/null 2>&1; then RECENTERED=true break fi done if ! $RECENTERED; then log "WARNING: initial recenter did not succeed — attack scores may be lower than expected" fi # ============================================================================= # Step 6 — Take base Anvil snapshot # # All attacks revert to this snapshot so they each start from the same state. # ============================================================================= BASE_SNAP=$(cast rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"') log "Base snapshot: $BASE_SNAP" # ============================================================================= # Steps 7–8 — Run each attack and accumulate lm_eth_total # ============================================================================= TOTAL_ETH=0 ATTACK_COUNT=0 for ATTACK_JSONL in "$ATTACKS_DIR"/*.jsonl; do [ -f "$ATTACK_JSONL" ] || continue ATTACK_NAME="$(basename "$ATTACK_JSONL" .jsonl)" log "Running attack: $ATTACK_NAME" # a. Take per-attack snapshot (identical to base on first iteration; # on subsequent iterations the state is already back at base from the # previous revert). ATK_SNAP=$(cast rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"') # b. Run AttackRunner, capturing all output (console.log snapshots come via # stdout when using --broadcast; stderr carries compilation noise). ATK_OUT="$WORK_DIR/atk-${ATTACK_NAME}.txt" ATK_EC=0 ( cd "$ONCHAIN_DIR" ATTACK_FILE="$ATTACK_JSONL" \ forge script script/backtesting/AttackRunner.s.sol \ --rpc-url "$RPC_URL" --broadcast --no-color 2>&1 ) >"$ATK_OUT" || ATK_EC=$? if [ "$ATK_EC" -ne 0 ]; then log " WARNING: AttackRunner failed for $ATTACK_NAME (exit $ATK_EC) — skipping" cast rpc anvil_revert "$ATK_SNAP" --rpc-url "$RPC_URL" >/dev/null 2>&1 || true continue fi # c. Extract lm_eth_total from the final JSON snapshot. # Snapshot lines are emitted by console.log and start with '{'. ETH_RETAINED=$(python3 - "$ATK_OUT" <<'PYEOF' import sys, json snapshots = [] with open(sys.argv[1]) as f: for line in f: line = line.strip() if line.startswith('{') and '"lm_eth_total"' in line: try: snapshots.append(json.loads(line)) except json.JSONDecodeError: pass if snapshots: # lm_eth_total is a quoted integer string in the snapshot JSON. val = snapshots[-1]['lm_eth_total'] print(int(val) if isinstance(val, str) else val) else: print(0) PYEOF ) log " $ATTACK_NAME: lm_eth_total=$ETH_RETAINED" TOTAL_ETH=$(python3 -c "print(int('$TOTAL_ETH') + int('$ETH_RETAINED'))") ATTACK_COUNT=$((ATTACK_COUNT + 1)) # d. Revert to per-attack snapshot — resets Anvil state to post-bootstrap # baseline so the next attack starts from the same conditions. cast rpc anvil_revert "$ATK_SNAP" --rpc-url "$RPC_URL" >/dev/null 2>&1 || true done # ============================================================================= # Output # ============================================================================= if [ "$ATTACK_COUNT" -eq 0 ]; then fail2 "No attacks ran — check $ATTACKS_DIR for *.jsonl files" fi log "Score: $TOTAL_ETH wei (sum of lm_eth_total across $ATTACK_COUNT attacks)" echo "$TOTAL_ETH"