harb/tools/push3-evolution/fitness.sh

281 lines
10 KiB
Bash
Raw Normal View History

#!/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 <candidate.push3>
#
# 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 <candidate.push3>" >&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 13 — 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 78 — 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"