harb/docs/mainnet-bootstrap.md
openhands 023c661ee7 fix: No mainnet VWAP bootstrap runbook (#728)
Add docs/mainnet-bootstrap.md with the full two-phase bootstrap
sequence: pool init, 300 s TWAP warm-up wait, first recenter + seed
buy (exact cast commands), 60 s cooldown wait, second recenter via
BootstrapVWAPPhase2.s.sol, and verification/troubleshooting steps.

Update the inline bootstrap comment in DeployBase.sol to warn that the
attempt always reverts on a fresh pool and direct operators to the new
runbook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 18:04:11 +00:00

259 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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. The inline bootstrap section in `DeployBase.sol` will revert on a fresh pool — that is expected. All contract deployments that precede the bootstrap attempt will succeed and be confirmed on-chain.
```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 Forge aborts the simulation before broadcast due to the `recenter()` revert, see [Troubleshooting](#troubleshooting) below 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
# Struct fields: tokenIn, tokenOut, fee, recipient, deadline, amountIn, amountOutMinimum, sqrtPriceLimitX96
DEADLINE=$(( $(cast block latest --rpc-url $BASE_RPC --field timestamp) + 1800 ))
cast send $SWAP_ROUTER \
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))(uint256)" \
"($WETH,$KRAIKEN,10000,$DEPLOYER_ADDRESS,$DEADLINE,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
export LM_ADDRESS="$LM_ADDRESS" # must be set
forge script script/BootstrapVWAPPhase2.s.sol \
--tc BootstrapVWAPPhase2 \
--rpc-url $BASE_RPC \
--broadcast \
--private-key $DEPLOYER_KEY
```
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
```
---
## 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 (lines marked `VWAP Bootstrap`) in `DeployBase.sol` locally before running the deploy script, then restore it afterward. The bootstrap is then performed manually using Steps 36 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 56.