Merge pull request 'fix: No mainnet VWAP bootstrap runbook (#728)' (#768) from fix/issue-728 into master
This commit is contained in:
commit
a734c5b3f6
3 changed files with 291 additions and 20 deletions
1
STATE.md
1
STATE.md
|
|
@ -27,3 +27,4 @@
|
|||
- [2026-03-14] evolve.sh stale tmpdirs break subsequent runs (#750)
|
||||
- [2026-03-14] evolve.sh silences all batch-eval errors with 2>/dev/null (#749)
|
||||
- [2026-03-14] evolution-daemon.sh — perpetual evolution loop on DO box (#748)
|
||||
- [2026-03-14] No mainnet VWAP bootstrap runbook (#728)
|
||||
|
|
|
|||
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. `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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
@ -101,19 +101,23 @@ contract DeployBase is Script {
|
|||
// =====================================================================
|
||||
// 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
|
||||
// price exists at the time of the first fee event. An attacker who
|
||||
// front-runs deployment with a whale buy inflates that anchor.
|
||||
//
|
||||
// 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.
|
||||
// front-runs deployment with a whale buy inflates that anchor; executing
|
||||
// the seed buy before handing control to users closes that window.
|
||||
//
|
||||
// Deployer must have SEED_LM_ETH + SEED_SWAP_ETH available (≈0.015 ETH).
|
||||
// =====================================================================
|
||||
|
|
@ -124,17 +128,24 @@ contract DeployBase is Script {
|
|||
console.log("feeDestination set to", feeDest);
|
||||
|
||||
// Step 2: Fund LM and place initial bootstrap positions.
|
||||
// NOTE: recenter() requires TWAP history (>= 300s since pool init).
|
||||
// On Base mainnet this call will revert if the pool is too fresh.
|
||||
// recenter() requires TWAP history (>= 300s since pool init).
|
||||
// On a fresh mainnet pool this will revert; the try/catch allows the
|
||||
// deploy to succeed and the operator completes the bootstrap manually
|
||||
// by following docs/mainnet-bootstrap.md.
|
||||
(bool funded,) = address(liquidityManager).call{ value: SEED_LM_ETH }("");
|
||||
require(funded, "Failed to fund LM for seed bootstrap");
|
||||
liquidityManager.recenter();
|
||||
console.log("First recenter complete -> positions placed, cumulativeVolume still 0");
|
||||
try liquidityManager.recenter() {
|
||||
console.log("First recenter complete -> positions placed, cumulativeVolume still 0");
|
||||
|
||||
// Step 3: Seed buy -> generates a non-zero fee in the anchor position.
|
||||
SeedSwapper seedSwapper = new SeedSwapper(weth, address(pool), token0isWeth);
|
||||
seedSwapper.executeSeedBuy{ value: SEED_SWAP_ETH }(sender);
|
||||
console.log("Seed buy executed -> fee generated in anchor position");
|
||||
// Step 3: Seed buy -> generates a non-zero fee in the anchor position.
|
||||
SeedSwapper seedSwapper = new SeedSwapper(weth, address(pool), token0isWeth);
|
||||
seedSwapper.executeSeedBuy{ value: SEED_SWAP_ETH }(sender);
|
||||
console.log("Seed buy executed -> fee generated in anchor position");
|
||||
} catch {
|
||||
console.log("WARNING: recenter() reverted - inline bootstrap skipped.");
|
||||
console.log(" Pool likely has < 300 s of TWAP history.");
|
||||
console.log(" Follow docs/mainnet-bootstrap.md (Phase 2 steps) to complete.");
|
||||
}
|
||||
|
||||
// Step 4: Second recenter records VWAP (bootstrap path + ethFee > 0).
|
||||
// Cannot be called in the same Forge broadcast as Step 2 — recenter() enforces a
|
||||
|
|
@ -149,7 +160,7 @@ contract DeployBase is Script {
|
|||
console.log("Optimizer:", optimizerAddress);
|
||||
console.log("\nPost-deploy steps:");
|
||||
console.log(" 1. Wait >= 60 s after this script finishes.");
|
||||
console.log(" 2. Run: forge script script/BootstrapVWAPPhase2.s.sol --tc BootstrapVWAPPhase2 --fork-url <RPC> --broadcast");
|
||||
console.log(" 2. Run: forge script script/BootstrapVWAPPhase2.s.sol --tc BootstrapVWAPPhase2 --rpc-url <RPC> --broadcast");
|
||||
console.log(" This performs the second recenter that records cumulativeVolume > 0.");
|
||||
console.log(" 3. Fund LiquidityManager with operational ETH.");
|
||||
console.log(" 4. recenter() is permissionless - any address (e.g. txnBot) can call it.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue