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>
This commit is contained in:
parent
9a9f0bc603
commit
023c661ee7
2 changed files with 274 additions and 11 deletions
259
docs/mainnet-bootstrap.md
Normal file
259
docs/mainnet-bootstrap.md
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
# 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 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.
|
||||||
|
|
@ -101,19 +101,23 @@ contract DeployBase is Script {
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// VWAP Bootstrap -> seed trade during deployment
|
// VWAP Bootstrap -> seed trade during deployment
|
||||||
//
|
//
|
||||||
|
// WARNING: On Base mainnet this inline attempt WILL REVERT when the pool
|
||||||
|
// was created in this same script run. The Uniswap V3 TWAP oracle
|
||||||
|
// requires >= 300 s of observation history before recenter() succeeds,
|
||||||
|
// and the 60-second recenter cooldown prevents completing both recenters
|
||||||
|
// in a single broadcast.
|
||||||
|
//
|
||||||
|
// Follow docs/mainnet-bootstrap.md for the correct two-phase manual
|
||||||
|
// sequence:
|
||||||
|
// Phase 1 — deploy contracts (this script, bootstrap section expected
|
||||||
|
// to revert on fresh pool)
|
||||||
|
// Phase 2 — wait >= 300 s, fund LM, first recenter(), seed buy
|
||||||
|
// Phase 3 — wait >= 60 s, run BootstrapVWAPPhase2.s.sol
|
||||||
|
//
|
||||||
// The cumulativeVolume==0 path in recenter() records VWAP from whatever
|
// The cumulativeVolume==0 path in recenter() records VWAP from whatever
|
||||||
// price exists at the time of the first fee event. An attacker who
|
// price exists at the time of the first fee event. An attacker who
|
||||||
// front-runs deployment with a whale buy inflates that anchor.
|
// front-runs deployment with a whale buy inflates that anchor; executing
|
||||||
//
|
// the seed buy before handing control to users closes that window.
|
||||||
// Fix: execute a small buy BEFORE handing control to users so that
|
|
||||||
// cumulativeVolume>0 by the time the protocol is live.
|
|
||||||
//
|
|
||||||
// recenter() is now permissionless and always enforces TWAP stability.
|
|
||||||
// For a fresh pool on Base mainnet this bootstrap must run at least
|
|
||||||
// 300 seconds after pool initialisation (so the TWAP oracle has history).
|
|
||||||
// If the pool was just created in this same script run, the first
|
|
||||||
// recenter() will revert with "price deviated from oracle" — wait 5 min
|
|
||||||
// and call the bootstrap as a separate transaction or script.
|
|
||||||
//
|
//
|
||||||
// Deployer must have SEED_LM_ETH + SEED_SWAP_ETH available (≈0.015 ETH).
|
// Deployer must have SEED_LM_ETH + SEED_SWAP_ETH available (≈0.015 ETH).
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue