From fbe838434258a246469ec268dbebdc5de7f0e9e1 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 22:24:05 +0000 Subject: [PATCH] fix: No recovery path if VWAP bootstrap fails mid-sequence (#644) Add recovery procedure documentation and automated recovery script for when the VWAP bootstrap fails partway through (e.g. second recenter reverts due to insufficient price movement). - Add "Recovery from failed mid-sequence bootstrap" section to docs/mainnet-bootstrap.md with diagnosis steps and manual recovery - Create scripts/recover-bootstrap.sh to automate diagnosis and retry - Add warning comments in BootstrapVWAPPhase2.s.sol, DeployBase.sol, and bootstrap-common.sh referencing the recovery procedure Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/mainnet-bootstrap.md | 66 +++++++ onchain/script/BootstrapVWAPPhase2.s.sol | 7 + onchain/script/DeployBase.sol | 5 + scripts/bootstrap-common.sh | 3 + scripts/recover-bootstrap.sh | 210 +++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100755 scripts/recover-bootstrap.sh diff --git a/docs/mainnet-bootstrap.md b/docs/mainnet-bootstrap.md index b8fc6f2..9010bc3 100644 --- a/docs/mainnet-bootstrap.md +++ b/docs/mainnet-bootstrap.md @@ -234,6 +234,72 @@ cast balance $LM_ADDRESS --rpc-url $BASE_RPC --- +## Recovery from failed mid-sequence bootstrap + +If the bootstrap fails partway through (e.g., the second `recenter()` in Step 6 reverts due to insufficient price movement / "amplitude not reached"), the LiquidityManager is left in a partially bootstrapped state: + +- **Positions deployed** — the first `recenter()` placed anchor, floor, and discovery positions +- **`cumulativeVolume == 0`** — the VWAP anchor was never recorded +- **`feeDestination` set** — `DeployBase.sol` sets this before any recenter attempt +- **`recenter()` is permissionless** — no access control to revoke; anyone can call it + +### Diagnosing the state + +```bash +# Check if VWAP bootstrap completed (0 = not yet bootstrapped) +cast call $LM_ADDRESS "cumulativeVolume()(uint256)" --rpc-url $BASE_RPC + +# Check current feeDestination +cast call $LM_ADDRESS "feeDestination()(address)" --rpc-url $BASE_RPC + +# Check if feeDestination is locked (true = cannot be changed) +cast call $LM_ADDRESS "feeDestinationLocked()(bool)" --rpc-url $BASE_RPC + +# Check if positions exist (non-zero liquidity = positions deployed) +cast call $LM_ADDRESS "positions(uint256)(int24,int24,uint128)" 1 --rpc-url $BASE_RPC +``` + +### Recovery steps + +1. **Identify the failure cause** — check the revert reason from Step 6. Common causes: + - `"amplitude not reached."` — the seed buy did not move the price enough ticks for `recenter()` to accept the movement as significant + - `"price deviated from oracle"` — TWAP history is still insufficient + - `"recenter cooldown"` — 60 s has not elapsed since the last recenter + +2. **Fix the root cause:** + - For amplitude issues: execute a larger seed buy (Step 4 with more ETH) to generate more price movement and anchor fees + - For TWAP issues: wait longer for oracle history to accumulate + - For cooldown: simply wait 60 s + +3. **Retry the second recenter** — re-run Step 6 (`BootstrapVWAPPhase2.s.sol`) or call `recenter()` directly: + ```bash + cast send $LM_ADDRESS "recenter()" --rpc-url $BASE_RPC --private-key $DEPLOYER_KEY + ``` + +4. **Verify** — confirm `cumulativeVolume > 0` (Step 7) + +5. **If `feeDestination` needs correction** (e.g., was never set or was set to the wrong address): + ```bash + # Only works if feeDestinationLocked is false + cast send $LM_ADDRESS \ + "setFeeDestination(address)" \ + --rpc-url $BASE_RPC \ + --private-key $DEPLOYER_KEY + ``` + +### Automated recovery + +A helper script automates the diagnosis and retry: + +```bash +# Diagnose and retry bootstrap +scripts/recover-bootstrap.sh --rpc-url $BASE_RPC --private-key $DEPLOYER_KEY --lm $LM_ADDRESS +``` + +See `scripts/recover-bootstrap.sh --help` for all options. + +--- + ## Troubleshooting ### `forge script` aborts before broadcast due to recenter() revert diff --git a/onchain/script/BootstrapVWAPPhase2.s.sol b/onchain/script/BootstrapVWAPPhase2.s.sol index 566cdd2..4bdc53a 100644 --- a/onchain/script/BootstrapVWAPPhase2.s.sol +++ b/onchain/script/BootstrapVWAPPhase2.s.sol @@ -18,6 +18,13 @@ import "forge-std/Script.sol"; * generated ethFee > 0, so recenter() records the VWAP anchor. * - Asserts cumulativeVolume > 0 to confirm bootstrap success. * + * WARNING: If this script reverts (e.g. "amplitude not reached" due to + * insufficient price movement from the seed buy), the LiquidityManager is + * left in a partially bootstrapped state with positions deployed but + * cumulativeVolume == 0. See docs/mainnet-bootstrap.md "Recovery from + * failed mid-sequence bootstrap" for the recovery procedure, or run + * scripts/recover-bootstrap.sh to diagnose and retry automatically. + * * Usage: * export LM_ADDRESS= * diff --git a/onchain/script/DeployBase.sol b/onchain/script/DeployBase.sol index 3078d45..afeb034 100644 --- a/onchain/script/DeployBase.sol +++ b/onchain/script/DeployBase.sol @@ -118,6 +118,11 @@ contract DeployBase is Script { // Phase 2 — wait >= 300 s, fund LM, first recenter(), seed buy // Phase 3 — wait >= 60 s, run BootstrapVWAPPhase2.s.sol // + // RECOVERY: If Phase 3 reverts mid-sequence, the LM is left with + // positions deployed but cumulativeVolume == 0. See + // docs/mainnet-bootstrap.md "Recovery from failed mid-sequence + // bootstrap" or run scripts/recover-bootstrap.sh. + // // The cumulativeVolume==0 path in recenter() records VWAP from whatever // price exists at the time of the first fee event. An attacker who // front-runs deployment with a whale buy inflates that anchor; executing diff --git a/scripts/bootstrap-common.sh b/scripts/bootstrap-common.sh index c4df644..c72b486 100755 --- a/scripts/bootstrap-common.sh +++ b/scripts/bootstrap-common.sh @@ -121,6 +121,9 @@ fund_liquidity_manager() { bootstrap_vwap() { detect_swap_router + # WARNING: If the second recenter() below fails mid-sequence, the LM is left + # with positions deployed but cumulativeVolume == 0 (partial bootstrap). + # For mainnet recovery see docs/mainnet-bootstrap.md or scripts/recover-bootstrap.sh. # Idempotency guard: if a previous run already bootstrapped VWAP, skip. local cumvol cumvol="$(cast call --rpc-url "$ANVIL_RPC" \ diff --git a/scripts/recover-bootstrap.sh b/scripts/recover-bootstrap.sh new file mode 100755 index 0000000..a49ec6e --- /dev/null +++ b/scripts/recover-bootstrap.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# recover-bootstrap.sh — Diagnose and recover from a failed VWAP bootstrap. +# +# If the second recenter() reverts mid-sequence (e.g. "amplitude not reached"), +# the LiquidityManager is left with positions deployed but cumulativeVolume == 0. +# This script diagnoses the state and retries the bootstrap. +# +# See docs/mainnet-bootstrap.md "Recovery from failed mid-sequence bootstrap" +# for the full manual procedure. +# +# Usage: +# scripts/recover-bootstrap.sh --rpc-url --private-key --lm [OPTIONS] +# +# Options: +# --rpc-url RPC endpoint (required) +# --private-key Deployer private key (required) +# --lm
LiquidityManager address (required) +# --kraiken
Kraiken token address (optional, for seed buy retry) +# --seed-eth Extra seed buy amount, e.g. "0.01ether" (default: skip seed buy) +# --fee-dest
Set feeDestination to this address if not already correct +# --dry-run Diagnose only, do not send transactions +# --help Show this help message +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────────────── +RPC_URL="" +PRIVATE_KEY="" +LM_ADDRESS="" +KRAIKEN="" +SEED_ETH="" +FEE_DEST="" +DRY_RUN=false + +WETH="0x4200000000000000000000000000000000000006" +SWAP_ROUTER_MAINNET="0x2626664c2603336E57B271c5C0b26F421741e481" +SWAP_ROUTER_SEPOLIA="0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4" +MAX_UINT="0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + +# ── Argument parsing ────────────────────────────────────────────────── +usage() { + sed -n '/^# Usage:/,/^set -euo/p' "$0" | head -n -1 | sed 's/^# \?//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --rpc-url) RPC_URL="$2"; shift 2 ;; + --private-key) PRIVATE_KEY="$2"; shift 2 ;; + --lm) LM_ADDRESS="$2"; shift 2 ;; + --kraiken) KRAIKEN="$2"; shift 2 ;; + --seed-eth) SEED_ETH="$2"; shift 2 ;; + --fee-dest) FEE_DEST="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --help|-h) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac +done + +if [[ -z "$RPC_URL" || -z "$PRIVATE_KEY" || -z "$LM_ADDRESS" ]]; then + echo "Error: --rpc-url, --private-key, and --lm are required." + echo "Run with --help for usage." + exit 1 +fi + +# ── Helpers ─────────────────────────────────────────────────────────── +info() { echo "[recover] $*"; } +warn() { echo "[recover] WARNING: $*"; } +error() { echo "[recover] ERROR: $*" >&2; } + +detect_swap_router() { + local chain_id + chain_id="$(cast chain-id --rpc-url "$RPC_URL" 2>/dev/null || echo "")" + if [[ "$chain_id" == "8453" ]]; then + echo "$SWAP_ROUTER_MAINNET" + else + echo "$SWAP_ROUTER_SEPOLIA" + fi +} + +# ── Step 1: Diagnose ───────────────────────────────────────────────── +info "Diagnosing LiquidityManager at $LM_ADDRESS ..." + +CUMVOL="$(cast call --rpc-url "$RPC_URL" "$LM_ADDRESS" "cumulativeVolume()(uint256)" 2>/dev/null || echo "ERROR")" +if [[ "$CUMVOL" == "ERROR" ]]; then + error "Cannot read cumulativeVolume — is LM_ADDRESS correct?" + exit 1 +fi + +FEE_DESTINATION="$(cast call --rpc-url "$RPC_URL" "$LM_ADDRESS" "feeDestination()(address)" 2>/dev/null || echo "ERROR")" +FEE_LOCKED="$(cast call --rpc-url "$RPC_URL" "$LM_ADDRESS" "feeDestinationLocked()(bool)" 2>/dev/null || echo "ERROR")" +LAST_RECENTER="$(cast call --rpc-url "$RPC_URL" "$LM_ADDRESS" "lastRecenterTime()(uint256)" 2>/dev/null || echo "0")" + +info " cumulativeVolume: $CUMVOL" +info " feeDestination: $FEE_DESTINATION" +info " feeDestinationLocked: $FEE_LOCKED" +info " lastRecenterTime: $LAST_RECENTER" + +# ── Check: already bootstrapped? ───────────────────────────────────── +if [[ "$CUMVOL" != "0" ]]; then + info "VWAP bootstrap already complete (cumulativeVolume=$CUMVOL). Nothing to recover." + exit 0 +fi + +info "cumulativeVolume is 0 — bootstrap incomplete, recovery needed." + +# ── Check: feeDestination ──────────────────────────────────────────── +if [[ "$FEE_DESTINATION" == "0x0000000000000000000000000000000000000000" ]]; then + warn "feeDestination is not set (zero address)." + if [[ -n "$FEE_DEST" ]]; then + info "Will set feeDestination to $FEE_DEST" + else + warn "Pass --fee-dest
to set it during recovery." + fi +fi + +if [[ -n "$FEE_DEST" && "$FEE_DESTINATION" != "$FEE_DEST" ]]; then + if [[ "$FEE_LOCKED" == "true" ]]; then + error "feeDestination is locked at $FEE_DESTINATION — cannot change to $FEE_DEST." + error "Manual intervention required." + exit 1 + fi + if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run] Would set feeDestination to $FEE_DEST" + else + info "Setting feeDestination to $FEE_DEST ..." + cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ + "$LM_ADDRESS" "setFeeDestination(address)" "$FEE_DEST" + info "feeDestination updated." + fi +fi + +# ── Check: cooldown ────────────────────────────────────────────────── +NOW="$(cast block latest --rpc-url "$RPC_URL" --field timestamp 2>/dev/null || echo "0")" +COOLDOWN_END=$(( LAST_RECENTER + 60 )) +if [[ "$NOW" -lt "$COOLDOWN_END" ]]; then + REMAINING=$(( COOLDOWN_END - NOW )) + warn "Recenter cooldown has not elapsed. ${REMAINING}s remaining." + warn "Wait and re-run this script." + if [[ "$DRY_RUN" == "true" ]]; then + exit 0 + fi + info "Waiting ${REMAINING}s for cooldown ..." + sleep "$REMAINING" +fi + +# ── Optional: extra seed buy ───────────────────────────────────────── +if [[ -n "$SEED_ETH" && -n "$KRAIKEN" ]]; then + SWAP_ROUTER="$(detect_swap_router)" + DEPLOYER_ADDR="$(cast wallet address --private-key "$PRIVATE_KEY")" + + if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run] Would execute seed buy: $SEED_ETH WETH -> KRAIKEN" + else + info "Executing seed buy ($SEED_ETH WETH -> KRAIKEN) ..." + + SEED_WEI="$(cast --to-unit "$SEED_ETH" wei)" + cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ + "$WETH" "deposit()" --value "$SEED_ETH" + cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ + "$WETH" "approve(address,uint256)" "$SWAP_ROUTER" "$MAX_UINT" + + # Determine swap direction from token ordering + WETH_LOWER=$(echo "$WETH" | tr '[:upper:]' '[:lower:]' | sed 's/^0x//') + KRAIKEN_LOWER=$(echo "$KRAIKEN" | tr '[:upper:]' '[:lower:]' | sed 's/^0x//') + if [[ "$WETH_LOWER" < "$KRAIKEN_LOWER" ]]; then + SQRT_LIMIT=4295128740 + else + SQRT_LIMIT=1461446703485210103287273052203988822378723970341 + fi + + cast send --legacy --gas-limit 300000 --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ + "$SWAP_ROUTER" "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($WETH,$KRAIKEN,10000,$DEPLOYER_ADDR,$SEED_WEI,0,$SQRT_LIMIT)" + info "Seed buy complete." + + # Wait for cooldown after potential recenter trigger + info "Waiting 65s for recenter cooldown ..." + sleep 65 + fi +fi + +# ── Retry second recenter ──────────────────────────────────────────── +if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run] Would call recenter() on $LM_ADDRESS" + info "[dry-run] Done. Re-run without --dry-run to execute." + exit 0 +fi + +info "Calling recenter() to complete VWAP bootstrap ..." +if ! cast send --rpc-url "$RPC_URL" --private-key "$PRIVATE_KEY" \ + "$LM_ADDRESS" "recenter()" 2>&1; then + error "recenter() reverted. Check the revert reason above." + error "Common causes:" + error " - 'amplitude not reached' -> need larger seed buy (use --seed-eth with --kraiken)" + error " - 'price deviated from oracle' -> wait for TWAP history" + error " - 'recenter cooldown' -> wait 60s and retry" + exit 1 +fi + +# ── Verify ──────────────────────────────────────────────────────────── +CUMVOL="$(cast call --rpc-url "$RPC_URL" "$LM_ADDRESS" "cumulativeVolume()(uint256)" 2>/dev/null || echo "0")" +if [[ "$CUMVOL" == "0" || -z "$CUMVOL" ]]; then + error "Recovery failed — cumulativeVolume is still 0." + error "The anchor position may have no fees. Try a larger seed buy:" + error " $0 --rpc-url $RPC_URL --private-key --lm $LM_ADDRESS --kraiken --seed-eth 0.01ether" + exit 1 +fi + +info "Recovery successful! cumulativeVolume=$CUMVOL" +info "VWAP bootstrap is complete."