From 023c661ee78770cdcace9f92af5672879560adb7 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 14 Mar 2026 18:04:11 +0000 Subject: [PATCH 1/3] 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 --- docs/mainnet-bootstrap.md | 259 ++++++++++++++++++++++++++++++++++ onchain/script/DeployBase.sol | 26 ++-- 2 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 docs/mainnet-bootstrap.md diff --git a/docs/mainnet-bootstrap.md b/docs/mainnet-bootstrap.md new file mode 100644 index 0000000..63abafd --- /dev/null +++ b/docs/mainnet-bootstrap.md @@ -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" +export BASESCAN_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. diff --git a/onchain/script/DeployBase.sol b/onchain/script/DeployBase.sol index 5b524ef..15c58fd 100644 --- a/onchain/script/DeployBase.sol +++ b/onchain/script/DeployBase.sol @@ -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). // ===================================================================== From 973caff0623a0b43390c6af050d39221fb13a754 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 14 Mar 2026 18:04:24 +0000 Subject: [PATCH 2/3] fix: No mainnet VWAP bootstrap runbook (#728) --- STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATE.md b/STATE.md index 242481d..47a1ea5 100644 --- a/STATE.md +++ b/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) From 4d16a51650d1da2cc1f6b11ab0087f135b71186c Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 14 Mar 2026 18:35:48 +0000 Subject: [PATCH 3/3] fix: address review feedback on mainnet-bootstrap runbook (#728) - docs/mainnet-bootstrap.md: fix Step 4c to use SwapRouter02 7-field struct (no deadline field); the 8-field ABI was for SwapRouter v1 but the address is SwapRouter02 - docs/mainnet-bootstrap.md: correct Step 1 to no longer falsely claim that pre-bootstrap transactions succeed when Forge aborts on simulation failure; Step 1 now reflects the try/catch behaviour added below - docs/mainnet-bootstrap.md: Step 6 drops --private-key flag (Foundry ignores it when vm.startBroadcast(privateKey) is called internally) and documents that the .secret seed-phrase file must be present - docs/mainnet-bootstrap.md: remove no-op `export LM_ADDRESS="$LM_ADDRESS"` - docs/mainnet-bootstrap.md: cite exact line range (101-145) in Troubleshooting workaround instead of informal marker description - onchain/script/DeployBase.sol: wrap liquidityManager.recenter() and seed buy in try/catch so a fresh-pool TWAP revert skips the inline bootstrap with a warning rather than aborting the entire simulation - onchain/script/DeployBase.sol: fix --fork-url to --rpc-url in the post-deploy console.log hint Co-Authored-By: Claude Sonnet 4.6 --- docs/mainnet-bootstrap.md | 24 ++++++++++++------------ onchain/script/DeployBase.sol | 25 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/docs/mainnet-bootstrap.md b/docs/mainnet-bootstrap.md index 63abafd..b8fc6f2 100644 --- a/docs/mainnet-bootstrap.md +++ b/docs/mainnet-bootstrap.md @@ -39,7 +39,7 @@ export DEPLOYER_ADDRESS="$(cast wallet address --private-key $DEPLOYER_KEY)" ## 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. +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 @@ -53,7 +53,7 @@ forge script script/DeployBaseMainnet.sol \ --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. +> **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: @@ -155,12 +155,11 @@ cast send $WETH \ --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 )) - +# 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,uint256,uint160))(uint256)" \ - "($WETH,$KRAIKEN,10000,$DEPLOYER_ADDRESS,$DEADLINE,5000000000000000,0,0)" \ + "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 @@ -199,13 +198,14 @@ done 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 - +# 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 \ - --private-key $DEPLOYER_KEY + --broadcast ``` The script asserts `cumulativeVolume > 0` and will fail with an explicit message if the bootstrap did not succeed. @@ -240,7 +240,7 @@ cast balance $LM_ADDRESS --rpc-url $BASE_RPC 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. +**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" diff --git a/onchain/script/DeployBase.sol b/onchain/script/DeployBase.sol index 15c58fd..8f66cdf 100644 --- a/onchain/script/DeployBase.sol +++ b/onchain/script/DeployBase.sol @@ -128,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 @@ -153,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 --broadcast"); + console.log(" 2. Run: forge script script/BootstrapVWAPPhase2.s.sol --tc BootstrapVWAPPhase2 --rpc-url --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.");