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) <noreply@anthropic.com>
325 lines
12 KiB
Markdown
325 lines
12 KiB
Markdown
# Mainnet VWAP Bootstrap Runbook
|
||
|
||
**Target chain:** Base (chain ID 8453)
|
||
|
||
## Why a manual process?
|
||
|
||
The VWAP bootstrap cannot be completed in a single Forge script execution. Two hard time-based delays imposed by the contracts make this impossible:
|
||
|
||
1. **300 s TWAP warm-up** — `recenter()` reads the Uniswap V3 TWAP oracle and reverts with `"price deviated from oracle"` if the pool has fewer than 300 seconds of observation history. A pool created within the same broadcast has zero history.
|
||
2. **60 s recenter cooldown** — `recenter()` enforces a per-call cooldown (`lastRecenterTime + 60 s`). The first and second recenters cannot share a single broadcast.
|
||
|
||
`DeployBase.sol` contains an inline bootstrap attempt that will always fail on a freshly-created pool. Follow this runbook instead.
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
```bash
|
||
# Required environment variables — set before starting
|
||
export BASE_RPC="https://mainnet.base.org" # or your preferred Base RPC
|
||
export DEPLOYER_KEY="0x<your-private-key>"
|
||
export BASESCAN_API_KEY="<your-api-key>"
|
||
|
||
# Populated after Step 1 (deploy)
|
||
export LM_ADDRESS="" # LiquidityManager proxy address
|
||
export KRAIKEN="" # Kraiken token address
|
||
export POOL="" # Uniswap V3 pool address
|
||
|
||
# Protocol constants (Base mainnet)
|
||
export WETH="0x4200000000000000000000000000000000000006"
|
||
export SWAP_ROUTER="0x2626664c2603336E57B271c5C0b26F421741e481" # Uniswap V3 SwapRouter02
|
||
export DEPLOYER_ADDRESS="$(cast wallet address --private-key $DEPLOYER_KEY)"
|
||
|
||
# Minimum ETH required in deployer wallet:
|
||
# gas for deploy (~0.05 ETH) + 0.01 ETH LM seed + 0.005 ETH seed buy
|
||
```
|
||
|
||
---
|
||
|
||
## Step 1 — Deploy contracts (pool init)
|
||
|
||
Run the mainnet deploy script. `DeployBase.sol` wraps the inline `recenter()` call in a try/catch, so if the pool is too fresh for the TWAP oracle the bootstrap is skipped with a warning and the deployment still succeeds. The deploy script then prints instructions directing you to complete the bootstrap manually.
|
||
|
||
```bash
|
||
cd onchain
|
||
|
||
forge script script/DeployBaseMainnet.sol \
|
||
--rpc-url $BASE_RPC \
|
||
--broadcast \
|
||
--verify \
|
||
--etherscan-api-key $BASESCAN_API_KEY \
|
||
--slow \
|
||
--private-key $DEPLOYER_KEY
|
||
```
|
||
|
||
> **Note:** If the script still aborts during simulation (e.g., due to an older version of `DeployBase.sol` without the try/catch), see [Troubleshooting](#troubleshooting) for how to separate the deploy from the bootstrap.
|
||
|
||
After the broadcast completes, record the addresses from the console output:
|
||
|
||
```bash
|
||
export LM_ADDRESS="0x..." # LiquidityManager address from deploy output
|
||
export KRAIKEN="0x..." # Kraiken address from deploy output
|
||
export POOL="0x..." # Uniswap V3 pool address from deploy output
|
||
```
|
||
|
||
Verify the pool exists and has been initialized:
|
||
|
||
```bash
|
||
cast call $POOL "slot0()" --rpc-url $BASE_RPC
|
||
# Returns: sqrtPriceX96, tick, ... (non-zero sqrtPriceX96 confirms initialization)
|
||
```
|
||
|
||
Record the block timestamp of pool creation:
|
||
|
||
```bash
|
||
export POOL_INIT_TS=$(cast block latest --rpc-url $BASE_RPC --field timestamp)
|
||
echo "Pool initialized at Unix timestamp: $POOL_INIT_TS"
|
||
echo "First recenter available after: $(( POOL_INIT_TS + 300 )) ($(date -d @$(( POOL_INIT_TS + 300 )) 2>/dev/null || date -r $(( POOL_INIT_TS + 300 )) 2>/dev/null))"
|
||
```
|
||
|
||
---
|
||
|
||
## Step 2 — Wait ≥ 300 s (TWAP warm-up)
|
||
|
||
The Uniswap V3 TWAP oracle must accumulate at least 300 seconds of observation history before `recenter()` can succeed. Do not proceed until 300 seconds have elapsed since pool initialization.
|
||
|
||
```bash
|
||
# Poll until 300 s have elapsed since pool creation
|
||
TARGET_TS=$(( POOL_INIT_TS + 300 ))
|
||
while true; do
|
||
NOW=$(cast block latest --rpc-url $BASE_RPC --field timestamp)
|
||
REMAINING=$(( TARGET_TS - NOW ))
|
||
if [ "$REMAINING" -le 0 ]; then
|
||
echo "TWAP warm-up complete. Proceeding to first recenter."
|
||
break
|
||
fi
|
||
echo "Waiting ${REMAINING}s more for TWAP warm-up..."
|
||
sleep 10
|
||
done
|
||
```
|
||
|
||
---
|
||
|
||
## Step 3 — Fund LiquidityManager and first recenter
|
||
|
||
Fund the LiquidityManager with the seed ETH it needs to place bootstrap positions, then call `recenter()` for the first time.
|
||
|
||
```bash
|
||
# Fund LiquidityManager (0.01 ETH minimum for bootstrap positions)
|
||
cast send $LM_ADDRESS \
|
||
--value 0.01ether \
|
||
--rpc-url $BASE_RPC \
|
||
--private-key $DEPLOYER_KEY
|
||
|
||
# Confirm balance
|
||
cast balance $LM_ADDRESS --rpc-url $BASE_RPC
|
||
```
|
||
|
||
```bash
|
||
# First recenter — places anchor, floor, and discovery positions
|
||
cast send $LM_ADDRESS \
|
||
"recenter()" \
|
||
--rpc-url $BASE_RPC \
|
||
--private-key $DEPLOYER_KEY
|
||
|
||
echo "First recenter complete."
|
||
```
|
||
|
||
Record the timestamp immediately after this call — the 60 s cooldown starts now:
|
||
|
||
```bash
|
||
export FIRST_RECENTER_TS=$(cast block latest --rpc-url $BASE_RPC --field timestamp)
|
||
echo "First recenter at Unix timestamp: $FIRST_RECENTER_TS"
|
||
echo "Second recenter available after: $(( FIRST_RECENTER_TS + 60 ))"
|
||
```
|
||
|
||
---
|
||
|
||
## Step 4 — Seed buy (generate non-zero anchor fee)
|
||
|
||
The VWAP bootstrap path in `recenter()` only records the price anchor when `ethFee > 0` (i.e., when the anchor position has collected a fee). Execute a small buy of KRAIKEN to generate that fee.
|
||
|
||
```bash
|
||
# Step 4a — Wrap ETH to WETH
|
||
cast send $WETH \
|
||
"deposit()" \
|
||
--value 0.005ether \
|
||
--rpc-url $BASE_RPC \
|
||
--private-key $DEPLOYER_KEY
|
||
|
||
# Step 4b — Approve SwapRouter to spend WETH
|
||
cast send $WETH \
|
||
"approve(address,uint256)" $SWAP_ROUTER 5000000000000000 \
|
||
--rpc-url $BASE_RPC \
|
||
--private-key $DEPLOYER_KEY
|
||
|
||
# Step 4c — Seed buy: swap 0.005 WETH → KRAIKEN via the 1 % pool
|
||
# SwapRouter02 exactInputSingle struct (7 fields — no deadline):
|
||
# tokenIn, tokenOut, fee, recipient, amountIn, amountOutMinimum, sqrtPriceLimitX96
|
||
cast send $SWAP_ROUTER \
|
||
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))(uint256)" \
|
||
"($WETH,$KRAIKEN,10000,$DEPLOYER_ADDRESS,5000000000000000,0,0)" \
|
||
--rpc-url $BASE_RPC \
|
||
--private-key $DEPLOYER_KEY
|
||
|
||
echo "Seed buy complete. Anchor position has collected a fee."
|
||
```
|
||
|
||
Confirm the pool executed the swap (non-zero KRK balance in deployer wallet):
|
||
|
||
```bash
|
||
cast call $KRAIKEN "balanceOf(address)" $DEPLOYER_ADDRESS --rpc-url $BASE_RPC
|
||
# Should be > 0
|
||
```
|
||
|
||
---
|
||
|
||
## Step 5 — Wait ≥ 60 s (recenter cooldown)
|
||
|
||
```bash
|
||
TARGET_TS=$(( FIRST_RECENTER_TS + 60 ))
|
||
while true; do
|
||
NOW=$(cast block latest --rpc-url $BASE_RPC --field timestamp)
|
||
REMAINING=$(( TARGET_TS - NOW ))
|
||
if [ "$REMAINING" -le 0 ]; then
|
||
echo "Recenter cooldown elapsed. Proceeding to second recenter."
|
||
break
|
||
fi
|
||
echo "Waiting ${REMAINING}s more for recenter cooldown..."
|
||
sleep 5
|
||
done
|
||
```
|
||
|
||
---
|
||
|
||
## Step 6 — Second recenter (records VWAP anchor)
|
||
|
||
The second `recenter()` hits the bootstrap path inside `LiquidityManager`: `cumulativeVolume == 0` and `ethFee > 0`, so it records the VWAP price anchor and sets `cumulativeVolume > 0`, permanently closing the bootstrap window.
|
||
|
||
```bash
|
||
# LM_ADDRESS must already be set from Step 1.
|
||
# BootstrapVWAPPhase2.s.sol reads the broadcaster key from the .secret
|
||
# seed-phrase file in onchain/ (same as DeployBase.sol). Ensure that file
|
||
# is present; the --private-key CLI flag is NOT used by this script.
|
||
forge script script/BootstrapVWAPPhase2.s.sol \
|
||
--tc BootstrapVWAPPhase2 \
|
||
--rpc-url $BASE_RPC \
|
||
--broadcast
|
||
```
|
||
|
||
The script asserts `cumulativeVolume > 0` and will fail with an explicit message if the bootstrap did not succeed.
|
||
|
||
---
|
||
|
||
## Step 7 — Verify bootstrap success
|
||
|
||
```bash
|
||
# cumulativeVolume must be > 0
|
||
cast call $LM_ADDRESS "cumulativeVolume()" --rpc-url $BASE_RPC
|
||
# Expected: non-zero value
|
||
|
||
# VWAP should now reflect the seed buy price
|
||
cast call $LM_ADDRESS "getVWAP()" --rpc-url $BASE_RPC 2>/dev/null || \
|
||
echo "(getVWAP may not be a public function — check cumulativeVolume above)"
|
||
|
||
# Three positions should be in place
|
||
cast call $LM_ADDRESS "positions(0)" --rpc-url $BASE_RPC # floor
|
||
cast call $LM_ADDRESS "positions(1)" --rpc-url $BASE_RPC # anchor
|
||
cast call $LM_ADDRESS "positions(2)" --rpc-url $BASE_RPC # discovery
|
||
|
||
# LM should hold ETH / WETH for ongoing operations
|
||
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)" <CORRECT_FEE_DEST_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
|
||
|
||
Foundry simulates the entire `run()` function before broadcasting anything. If the inline bootstrap in `DeployBase.sol` causes the simulation to fail, no transactions are broadcast.
|
||
|
||
**Workaround:** Comment out the bootstrap block in `DeployBase.sol` locally (lines 101–145, from `// =====================================================================` through `seedSwapper.executeSeedBuy{ value: SEED_SWAP_ETH }(sender);`) before running the deploy script, then restore it afterward. The bootstrap is then performed manually using Steps 3–6 above.
|
||
|
||
### `recenter()` reverts with "price deviated from oracle"
|
||
|
||
The pool has insufficient TWAP history. Wait longer and retry. At least one block must have been produced with the pool at its initialized price before the 300 s counter begins.
|
||
|
||
### `recenter()` reverts with "cooldown"
|
||
|
||
The 60 s cooldown has not elapsed since the last recenter. Wait and retry.
|
||
|
||
### Seed buy produces zero KRK
|
||
|
||
The pool may have no in-range liquidity (first recenter did not place positions successfully). Check positions via `cast call $LM_ADDRESS "positions(1)"` and re-run Step 3 if the anchor position is empty.
|
||
|
||
### BootstrapVWAPPhase2 fails with "cumulativeVolume is still 0"
|
||
|
||
The anchor position collected no fees — either the seed buy was too small to generate a fee, or the swap routed through a different pool. Repeat Step 4 with a larger `amountIn` (e.g., `0.01 ether` / `10000000000000000`) and re-run Step 5–6.
|