#!/usr/bin/env bash # ============================================================================= # deploy-optimizer.sh — Unified Push3 → deploy pipeline # # Pipeline: Push3 file → transpiler → Solidity → forge compile → UUPS upgrade # # Usage: # ./tools/deploy-optimizer.sh [--live] # # Flags: # --live Target mainnet/testnet (requires OPTIMIZER_PROXY and RPC_URL env vars). # Without this flag the script targets a local Anvil instance. # # Environment (--live mode only): # OPTIMIZER_PROXY Address of the deployed UUPS proxy to upgrade. # RPC_URL JSON-RPC endpoint. # SECRET_FILE Path to seed-phrase file (default: onchain/.secret). # # Environment (dry-run mode only): # ANVIL_FORK_URL Required when Anvil is not already running. Must point to # a Base RPC endpoint so that forked state includes Uniswap V3 # Factory and WETH. # ============================================================================= set -euo pipefail # Foundry tools (forge, cast, anvil) export PATH="${HOME}/.foundry/bin:${PATH}" # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ONCHAIN_DIR="$REPO_ROOT/onchain" TRANSPILER_DIR="$SCRIPT_DIR/push3-transpiler" TRANSPILER_OUT="$ONCHAIN_DIR/src/OptimizerV3Push3.sol" # --------------------------------------------------------------------------- # Parse arguments # --------------------------------------------------------------------------- LIVE=false PUSH3_FILE="" for arg in "$@"; do case "$arg" in --live) LIVE=true ;; --*) echo "Error: Unknown option: $arg" >&2; exit 1 ;; *) PUSH3_FILE="$arg" ;; esac done if [ -z "$PUSH3_FILE" ]; then echo "Usage: $0 [--live] " >&2 exit 1 fi if [ ! -f "$PUSH3_FILE" ]; then echo "Error: File not found: $PUSH3_FILE" >&2 exit 1 fi # Make PUSH3_FILE absolute so it works regardless of cwd changes PUSH3_FILE="$(cd "$(dirname "$PUSH3_FILE")" && pwd)/$(basename "$PUSH3_FILE")" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- info() { echo " [info] $*"; } success() { echo " [ok] $*"; } step() { echo; echo "==> $*"; } fail() { echo; echo " [fail] $*" >&2; exit 1; } # Decode a uint256 returned by cast call (strips 0x prefix, converts hex→dec) decode_uint() { python3 -c "print(int('$1', 16))" 2>/dev/null || echo "0" } # Decode a bool returned by cast call decode_bool() { python3 -c "print('true' if int('$1', 16) != 0 else 'false')" 2>/dev/null || echo "false" } # Cleanup state ANVIL_PID="" SECRET_CREATED=false cleanup() { if [ -n "$ANVIL_PID" ]; then kill "$ANVIL_PID" 2>/dev/null || true fi if $SECRET_CREATED && [ -f "$ONCHAIN_DIR/.secret" ]; then rm -f "$ONCHAIN_DIR/.secret" fi rm -f /tmp/deploy-local-output.txt /tmp/new-optimizer-impl.txt \ /tmp/push3-test-addr.txt /tmp/upgrade-output.txt 2>/dev/null || true } trap cleanup EXIT # --------------------------------------------------------------------------- # Step 0 — Validate tooling # --------------------------------------------------------------------------- step "Checking required tools" for tool in forge cast npx node python3; do if ! command -v "$tool" &>/dev/null; then fail "$tool not found in PATH" fi done if ! $LIVE; then if ! command -v anvil &>/dev/null; then fail "anvil not found in PATH (required for dry-run mode)" fi fi success "Required tools are present" # --------------------------------------------------------------------------- # Step 1 — Transpile Push3 → Solidity # --------------------------------------------------------------------------- step "Transpiling $(basename "$PUSH3_FILE") → OptimizerV3Push3.sol" ( cd "$TRANSPILER_DIR" if [ ! -d node_modules ]; then info "Installing transpiler dependencies..." npm install --silent fi npx tsx src/index.ts "$PUSH3_FILE" "$TRANSPILER_OUT" ) success "Generated $TRANSPILER_OUT" # --------------------------------------------------------------------------- # Step 2 — Compile with forge # --------------------------------------------------------------------------- step "Compiling contracts (forge build)" ( cd "$ONCHAIN_DIR" forge build --silent ) success "Compilation succeeded" # --------------------------------------------------------------------------- # Step 3 — Setup network target # --------------------------------------------------------------------------- step "Setting up network target" OPTIMIZER_PROXY="${OPTIMIZER_PROXY:-}" if $LIVE; then # ---- Live / testnet mode ---- info "Mode: LIVE (mainnet/testnet)" RPC_URL="${RPC_URL:-}" if [ -z "$RPC_URL" ]; then fail "--live requires RPC_URL env var" fi if [ -z "$OPTIMIZER_PROXY" ]; then fail "--live requires OPTIMIZER_PROXY env var" fi SECRET_FILE="${SECRET_FILE:-$ONCHAIN_DIR/.secret}" if [ ! -f "$SECRET_FILE" ]; then fail "Secret file not found: $SECRET_FILE (set SECRET_FILE env var)" fi cast chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1 || \ fail "Cannot reach RPC endpoint: $RPC_URL" CHAIN_ID="$(cast chain-id --rpc-url "$RPC_URL")" info "Connected to chain $CHAIN_ID via $RPC_URL" info "Target proxy: $OPTIMIZER_PROXY" info "Key file: $SECRET_FILE" else # ---- Dry-run (Anvil) mode ---- info "Mode: DRY-RUN (local Anvil)" RPC_URL="http://localhost:8545" # Ensure onchain/.secret exists for UpgradeOptimizer.sol (uses vm.readFile) if [ ! -f "$ONCHAIN_DIR/.secret" ]; then cp "$ONCHAIN_DIR/.secret.local" "$ONCHAIN_DIR/.secret" SECRET_CREATED=true info "Created temporary onchain/.secret from .secret.local" fi SECRET_FILE="$ONCHAIN_DIR/.secret" # Check if Anvil is already running. # # DeployLocal.sol depends on live Base infrastructure: Uniswap V3 Factory at # 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24 and WETH at # 0x4200000000000000000000000000000000000006. A plain (unfork'd) Anvil has # neither, so cold-starting without --fork-url silently breaks the pipeline. # # When Anvil is already running (dev stack or CI), we use it as-is. # When it is not running we require ANVIL_FORK_URL and start a forked instance. if cast chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1; then info "Anvil already running at $RPC_URL" else ANVIL_FORK_URL="${ANVIL_FORK_URL:-}" if [ -z "$ANVIL_FORK_URL" ]; then fail "Anvil is not running at $RPC_URL and ANVIL_FORK_URL is not set. DeployLocal.sol requires Base network contracts (Uniswap V3 Factory, WETH). Either start a Base-forked Anvil externally, or set ANVIL_FORK_URL to a Base RPC endpoint (e.g. ANVIL_FORK_URL=https://mainnet.base.org)." fi info "Starting Anvil (fork: $ANVIL_FORK_URL)..." anvil --silent \ --fork-url "$ANVIL_FORK_URL" \ --mnemonic "test test test test test test test test test test test junk" \ --port 8545 & ANVIL_PID=$! # Poll until ready (no fixed sleeps) TRIES=0 until cast chain-id --rpc-url "$RPC_URL" >/dev/null 2>&1; do TRIES=$((TRIES + 1)) [ $TRIES -gt 50 ] && fail "Anvil did not start within 50 attempts" sleep 0.2 done info "Anvil started (PID $ANVIL_PID, fork: $ANVIL_FORK_URL)" fi # If no OPTIMIZER_PROXY set, deploy a fresh local stack if [ -z "$OPTIMIZER_PROXY" ]; then info "No OPTIMIZER_PROXY set — deploying fresh local stack via DeployLocal.sol" ( cd "$ONCHAIN_DIR" forge script script/DeployLocal.sol --tc DeployLocal \ --rpc-url "$RPC_URL" \ --broadcast 2>&1 | tee /tmp/deploy-local-output.txt ) CHAIN_ID="$(cast chain-id --rpc-url "$RPC_URL")" BROADCAST_JSON="$ONCHAIN_DIR/broadcast/DeployLocal.sol/$CHAIN_ID/run-latest.json" if [ -f "$BROADCAST_JSON" ]; then OPTIMIZER_PROXY="$(python3 - "$BROADCAST_JSON" <<'PYEOF' import json, sys path = sys.argv[1] with open(path) as f: data = json.load(f) txs = data.get('transactions', []) # The ERC1967Proxy is the optimizer proxy for tx in txs: name = (tx.get('contractName') or '').lower() if 'erc1967proxy' in name: print(tx.get('contractAddress', '')) sys.exit(0) # Fallback: look for "Optimizer:" in the deploy output print('') PYEOF )" fi # Fallback: grep the console log output if [ -z "$OPTIMIZER_PROXY" ]; then OPTIMIZER_PROXY="$(grep -oE 'Optimizer: 0x[0-9a-fA-F]{40}' \ /tmp/deploy-local-output.txt | awk '{print $2}' | tail -1 || true)" fi if [ -z "$OPTIMIZER_PROXY" ]; then fail "Could not determine OPTIMIZER_PROXY from fresh deployment. Set OPTIMIZER_PROXY manually." fi info "Fresh stack deployed. Optimizer proxy: $OPTIMIZER_PROXY" # Verify that the seed trade bootstrapped VWAP during deployment. # DeployLocal.sol runs a first recenter + seed buy + second recenter so that # cumulativeVolume>0 before any user can interact with the protocol. if [ ! -f "$BROADCAST_JSON" ]; then fail "Broadcast JSON not found: $BROADCAST_JSON — cannot verify VWAP bootstrap" fi LM_ADDR="" LM_ADDR="$(python3 - "$BROADCAST_JSON" <<'PYEOF' import json, sys with open(sys.argv[1]) as f: data = json.load(f) for tx in data.get('transactions', []): if (tx.get('contractName') or '').lower() == 'liquiditymanager': print(tx.get('contractAddress', '')) break PYEOF )" if [ -z "$LM_ADDR" ]; then info "WARNING: LiquidityManager address not found in $BROADCAST_JSON — skipping VWAP check" fi if [ -n "$LM_ADDR" ]; then CUMVOL_HEX="$(cast call "$LM_ADDR" "cumulativeVolume()(uint256)" \ --rpc-url "$RPC_URL" 2>/dev/null || echo "0x0")" CUMVOL="$(decode_uint "$CUMVOL_HEX")" if [ "$CUMVOL" -gt 0 ]; then success "VWAP bootstrapped: LiquidityManager.cumulativeVolume=$CUMVOL" else fail "VWAP not bootstrapped: cumulativeVolume=0 — seed trade may have failed" fi fi fi fi # Derive private key for forge create / cast call operations SEED="$(cat "$SECRET_FILE")" DEPLOYER_KEY="$(cast wallet derive-private-key "$SEED" 0)" DEPLOYER_ADDR="$(cast wallet address --private-key "$DEPLOYER_KEY")" info "Deployer: $DEPLOYER_ADDR" # --------------------------------------------------------------------------- # Step 4 — Capture pre-upgrade state # --------------------------------------------------------------------------- step "Capturing pre-upgrade optimizer parameters" # calculateSentiment(averageTaxRate, percentageStaked) is public pure — safe on both # old and new implementations without needing a live Stake contract. # # Reference input: 95% staked (95e16), 5% tax rate (5e16) REF_STAKED="950000000000000000" REF_TAXRATE="50000000000000000" PRE_RAW="$(cast call "$OPTIMIZER_PROXY" \ "calculateSentiment(uint256,uint256)(uint256)" \ "$REF_TAXRATE" "$REF_STAKED" \ --rpc-url "$RPC_URL" 2>/dev/null || echo "0x0")" PRE_SENTIMENT="$(decode_uint "$PRE_RAW")" info "Pre-upgrade calculateSentiment(staked=$REF_STAKED, tax=$REF_TAXRATE) = $PRE_SENTIMENT" # --------------------------------------------------------------------------- # Step 5 — Deploy new implementation (for diff preview only) # --------------------------------------------------------------------------- step "Deploying new Optimizer implementation for diff preview" ( cd "$ONCHAIN_DIR" forge create src/OptimizerV3.sol:OptimizerV3 \ --rpc-url "$RPC_URL" \ --private-key "$DEPLOYER_KEY" \ --json 2>/dev/null \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('deployedTo',''))" \ > /tmp/new-optimizer-impl.txt ) NEW_IMPL="$(cat /tmp/new-optimizer-impl.txt 2>/dev/null || echo "")" [ -z "$NEW_IMPL" ] && fail "Failed to deploy new OptimizerV3 implementation" info "New implementation deployed at: $NEW_IMPL" # calculateSentiment is pure — callable on bare (uninitialized) implementation NEW_RAW="$(cast call "$NEW_IMPL" \ "calculateSentiment(uint256,uint256)(uint256)" \ "$REF_TAXRATE" "$REF_STAKED" \ --rpc-url "$RPC_URL" 2>/dev/null || echo "0x0")" NEW_SENTIMENT="$(decode_uint "$NEW_RAW")" info "New impl calculateSentiment(staked=$REF_STAKED, tax=$REF_TAXRATE) = $NEW_SENTIMENT" # --------------------------------------------------------------------------- # Step 6 — Show parameter diff before upgrading # --------------------------------------------------------------------------- step "Parameter diff (before upgrade confirmation)" echo echo " ┌──────────────────────────────────────────────────────────────" echo " │ calculateSentiment(averageTaxRate=$REF_TAXRATE, percentageStaked=$REF_STAKED)" echo " ├──────────────────────────────────────────────────────────────" printf " │ Old (via proxy) : %s\n" "$PRE_SENTIMENT" printf " │ New (new impl) : %s\n" "$NEW_SENTIMENT" if [ "$PRE_SENTIMENT" = "$NEW_SENTIMENT" ]; then echo " │ Diff : none — implementations are semantically equivalent" else echo " │ Diff : CHANGED" fi echo " └──────────────────────────────────────────────────────────────" echo if $LIVE; then printf " Proceed with upgrade on LIVE network? [y/N] " read -r CONFIRM case "$CONFIRM" in y|Y|yes|YES) ;; *) echo "Upgrade cancelled."; exit 0 ;; esac fi # --------------------------------------------------------------------------- # Step 7 — Run UUPS upgrade via UpgradeOptimizer.sol # --------------------------------------------------------------------------- step "Running UUPS upgrade (UpgradeOptimizer.sol)" ( cd "$ONCHAIN_DIR" OPTIMIZER_PROXY="$OPTIMIZER_PROXY" \ forge script script/UpgradeOptimizer.sol \ --rpc-url "$RPC_URL" \ --broadcast 2>&1 | tee /tmp/upgrade-output.txt ) || fail "UpgradeOptimizer.sol script failed" success "Proxy upgraded" # Confirm new implementation address from broadcast log UPGRADED_IMPL="$(grep -oE 'New Optimizer implementation: 0x[0-9a-fA-F]{40}' \ /tmp/upgrade-output.txt | awk '{print $NF}' | tail -1 || true)" [ -n "$UPGRADED_IMPL" ] && info "Upgraded implementation: $UPGRADED_IMPL" # --------------------------------------------------------------------------- # Step 8 — Round-trip verification # --------------------------------------------------------------------------- step "Round-trip verification (OptimizerV3Push3 isBullMarket)" # Deploy the transpiled OptimizerV3Push3 as a standalone test fixture. # This contract has no constructor dependencies (pure functions only). ( cd "$ONCHAIN_DIR" forge create src/OptimizerV3Push3.sol:OptimizerV3Push3 \ --rpc-url "$RPC_URL" \ --private-key "$DEPLOYER_KEY" \ --json 2>/dev/null \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('deployedTo',''))" \ > /tmp/push3-test-addr.txt ) PUSH3_ADDR="$(cat /tmp/push3-test-addr.txt 2>/dev/null || echo "")" [ -z "$PUSH3_ADDR" ] && fail "Failed to deploy OptimizerV3Push3 for round-trip verification" info "OptimizerV3Push3 test fixture: $PUSH3_ADDR" # Test vectors derived from the Push3 program semantics: # # isBullMarket(percentageStaked, averageTaxRate) → # if stakedPct ≤ 91% → false (always bear) # else penalty = deltaS³ × effIdx / 20 # if penalty < 50 → true (bull) # else → false (bear) # # Vector 1 — Bear by staked threshold: # 90% staked, any tax → stakedPct=90 ≤ 91 → false # # Vector 2 — Bear by penalty: # 92% staked (deltaS=8), tax=1e17 → rawIdx=20, effIdx=21, # penalty = 512×21/20 = 537 ≥ 50 → false # # Vector 3 — Bull: # 99% staked (deltaS=1), tax=0 → rawIdx=0, effIdx=0, # penalty = 1×0/20 = 0 < 50 → true PASS=true VERIFY_LOG="" run_vector() { local label="$1" pct="$2" tax="$3" expected="$4" local raw actual raw="$(cast call "$PUSH3_ADDR" \ "isBullMarket(uint256,uint256)(bool)" \ "$pct" "$tax" \ --rpc-url "$RPC_URL" 2>/dev/null || echo "0x0")" actual="$(decode_bool "$raw")" if [ "$actual" = "$expected" ]; then VERIFY_LOG="$VERIFY_LOG\n [PASS] $label" else VERIFY_LOG="$VERIFY_LOG\n [FAIL] $label (got=$actual expected=$expected)" PASS=false fi } run_vector "Bear/threshold isBullMarket(90e16, 0) → false" \ "900000000000000000" "0" "false" run_vector "Bear/penalty isBullMarket(92e16, 1e17) → false" \ "920000000000000000" "100000000000000000" "false" run_vector "Bull/zero-tax isBullMarket(99e16, 0) → true" \ "990000000000000000" "0" "true" echo printf "%b\n" "$VERIFY_LOG" echo if $PASS; then success "Round-trip verification passed" else fail "Round-trip verification FAILED — transpiled isBullMarket does not match expected values" fi # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- echo echo "==> Pipeline complete" echo " Push3 source : $(basename "$PUSH3_FILE")" echo " Transpiled : $TRANSPILER_OUT" echo " Proxy : $OPTIMIZER_PROXY" echo " Network : $RPC_URL" exit 0