diff --git a/onchain/script/DeployBase.sol b/onchain/script/DeployBase.sol index 7e01747..de9065c 100644 --- a/onchain/script/DeployBase.sol +++ b/onchain/script/DeployBase.sol @@ -11,6 +11,7 @@ import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "forge-std/Script.sol"; +import "./DeployCommon.sol"; uint24 constant FEE = uint24(10_000); @@ -23,6 +24,12 @@ contract DeployBase is Script { address public v3Factory; address public optimizer; + // Seed amounts for VWAP bootstrap. + // Kept small: deployer only needs this ETH on top of gas. + // With very thin bootstrap positions, even 0.005 ETH moves the price >400 ticks. + uint256 internal constant SEED_LM_ETH = 0.01 ether; + uint256 internal constant SEED_SWAP_ETH = 0.005 ether; + // Deployed contracts Kraiken public kraiken; Stake public stake; @@ -88,12 +95,46 @@ contract DeployBase is Script { liquidityManager = new LiquidityManager(v3Factory, weth, address(kraiken), optimizerAddress); console.log("LiquidityManager deployed at:", address(liquidityManager)); - // Set fee destination - liquidityManager.setFeeDestination(feeDest); - // Set liquidity manager in Kraiken kraiken.setLiquidityManager(address(liquidityManager)); + // ===================================================================== + // VWAP Bootstrap -> seed trade during deployment + // + // 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. + // + // Deployer must have SEED_LM_ETH + SEED_SWAP_ETH available (≈0.015 ETH). + // ===================================================================== + console.log("\nBootstrapping VWAP with seed trade..."); + + // Step 1: Temporarily set deployer as feeDestination to call setRecenterAccess. + liquidityManager.setFeeDestination(sender); + liquidityManager.setRecenterAccess(sender); + + // Step 2: Fund LM and place initial bootstrap positions. + (bool funded,) = address(liquidityManager).call{ value: SEED_LM_ETH }(""); + require(funded, "Failed to fund LM for seed bootstrap"); + liquidityManager.recenter(); + + // 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); + + // Step 4: Second recenter records VWAP (bootstrap path + ethFee > 0). + liquidityManager.recenter(); + require(liquidityManager.cumulativeVolume() > 0, "VWAP bootstrap failed: cumulativeVolume is 0"); + console.log("VWAP bootstrapped -> cumulativeVolume:", liquidityManager.cumulativeVolume()); + + // Step 5: Clean up -> revoke temporary access and set the real feeDestination. + liquidityManager.revokeRecenterAccess(); + liquidityManager.setFeeDestination(feeDest); + console.log("recenterAccess revoked, feeDestination set to", feeDest); + console.log("\n=== Deployment Complete ==="); console.log("Kraiken:", address(kraiken)); console.log("Stake:", address(stake)); @@ -101,9 +142,9 @@ contract DeployBase is Script { console.log("LiquidityManager:", address(liquidityManager)); console.log("Optimizer:", optimizerAddress); console.log("\nPost-deploy steps:"); - console.log(" 1. Fund LiquidityManager with ETH"); + console.log(" 1. Fund LiquidityManager with operational ETH (VWAP already bootstrapped)"); console.log(" 2. Set recenterAccess to txnBot: lm.setRecenterAccess(txnBot) from feeDestination"); - console.log(" 3. Wait a few minutes, then call recenter()"); + console.log(" 3. txnBot can now call recenter()"); vm.stopBroadcast(); } diff --git a/onchain/script/DeployCommon.sol b/onchain/script/DeployCommon.sol new file mode 100644 index 0000000..5a5557b --- /dev/null +++ b/onchain/script/DeployCommon.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { IWETH9 } from "../src/interfaces/IWETH9.sol"; +import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; + +/** + * @title SeedSwapper + * @notice One-shot helper deployed during Deploy*.run() to perform the initial seed buy. + * Executing a small buy before the protocol opens eliminates the cumulativeVolume==0 + * front-run window: after the seed recenter, VWAP has a real anchor and the bootstrap + * path in LiquidityManager.recenter() is never reachable by external users. + * + * @dev Deployed by both DeployLocal and DeployBase; extracted here to avoid duplicate + * Foundry artifacts and drift between the two deploy scripts. + */ +contract SeedSwapper { + // TickMath sentinel values — the pool will consume as much input as available in-range. + uint160 private constant SQRT_PRICE_LIMIT_MIN = 4295128740; // TickMath.MIN_SQRT_RATIO + 1 + uint160 private constant SQRT_PRICE_LIMIT_MAX = // TickMath.MAX_SQRT_RATIO - 1 + 1461446703485210103287273052203988822378723970341; + + IWETH9 private immutable weth; + IUniswapV3Pool private immutable pool; + bool private immutable token0isWeth; + + constructor(address _weth, address _pool, bool _token0isWeth) { + weth = IWETH9(_weth); + pool = IUniswapV3Pool(_pool); + token0isWeth = _token0isWeth; + } + + /// @notice Wraps msg.value ETH to WETH, swaps it for KRK (buying KRK), and sweeps any + /// unconsumed WETH back to `recipient`. The fee generated by the swap is captured + /// in the LM's anchor position so the subsequent recenter() call will collect a + /// non-zero ethFee and record VWAP. + /// + /// @dev If the pool exhausts in-range liquidity before spending all WETH (price-limit + /// sentinel reached), `wethDelta < msg.value` in the callback and the remainder + /// stays as WETH in this contract. The post-swap sweep returns it to `recipient` + /// so no funds are stranded. + function executeSeedBuy(address recipient) external payable { + weth.deposit{ value: msg.value }(); + + // zeroForOne=true when WETH is token0: sell token0(WETH) -> token1(KRK) + // zeroForOne=false when WETH is token1: sell token1(WETH) -> token0(KRK) + bool zeroForOne = token0isWeth; + uint160 priceLimit = zeroForOne ? SQRT_PRICE_LIMIT_MIN : SQRT_PRICE_LIMIT_MAX; + + pool.swap(recipient, zeroForOne, int256(msg.value), priceLimit, ""); + + // Sweep any unconsumed WETH to recipient (occurs when price limit is reached + // before the full input is spent). + uint256 leftover = weth.balanceOf(address(this)); + if (leftover > 0) { + require(weth.transfer(recipient, leftover), "SeedSwapper: WETH sweep failed"); + } + } + + /// @notice Uniswap V3 callback: pay the WETH owed for the seed buy. + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external { + require(msg.sender == address(pool), "SeedSwapper: only pool"); + int256 wethDelta = token0isWeth ? amount0Delta : amount1Delta; + if (wethDelta > 0) { + require(weth.transfer(msg.sender, uint256(wethDelta)), "SeedSwapper: WETH transfer failed"); + } + } +} diff --git a/onchain/script/DeployLocal.sol b/onchain/script/DeployLocal.sol index 895e6a1..d294974 100644 --- a/onchain/script/DeployLocal.sol +++ b/onchain/script/DeployLocal.sol @@ -11,6 +11,7 @@ import { ERC1967Proxy } from "@openzeppelin/proxy/ERC1967/ERC1967Proxy.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Factory.sol"; import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; import "forge-std/Script.sol"; +import "./DeployCommon.sol"; /** * @title DeployLocal @@ -27,6 +28,17 @@ contract DeployLocal is Script { address internal constant weth = 0x4200000000000000000000000000000000000006; address internal constant v3Factory = 0x4752ba5DBc23f44D87826276BF6Fd6b1C372aD24; + // Seed amounts for VWAP bootstrap. + // seedLmEth: initial ETH sent to the LM to create thin bootstrap positions. + // seedSwapEth: ETH used for the seed buy. Must be large enough to move the + // Uniswap tick >400 ticks past the ANCHOR center (minAmplitude = 2*tickSpacing + // = 400 for the 1%-fee pool). The ANCHOR typically holds ~25% of seedLmEth as + // WETH across a ~7200-tick range; consuming half of that WETH (≈0.125 ETH) + // moves the price ~3600 ticks — well above the 400-tick threshold. + // 0.5 ether provides a 4× margin over the minimum needed. + uint256 internal constant SEED_LM_ETH = 1 ether; + uint256 internal constant SEED_SWAP_ETH = 0.5 ether; + // Deployed contracts Kraiken public kraiken; Stake public stake; @@ -48,7 +60,7 @@ contract DeployLocal is Script { // Deploy Kraiken token kraiken = new Kraiken("Kraiken", "KRK"); - console.log("\n[1/6] Kraiken deployed:", address(kraiken)); + console.log("\n[1/7] Kraiken deployed:", address(kraiken)); // Determine token ordering token0isWeth = address(weth) < address(kraiken); @@ -56,7 +68,7 @@ contract DeployLocal is Script { // Deploy Stake contract stake = new Stake(address(kraiken), feeDest); - console.log("\n[2/6] Stake deployed:", address(stake)); + console.log("\n[2/7] Stake deployed:", address(stake)); // Set staking pool in Kraiken kraiken.setStakingPool(address(stake)); @@ -67,9 +79,9 @@ contract DeployLocal is Script { address liquidityPool = factory.getPool(weth, address(kraiken), FEE); if (liquidityPool == address(0)) { liquidityPool = factory.createPool(weth, address(kraiken), FEE); - console.log("\n[3/6] Uniswap pool created:", liquidityPool); + console.log("\n[3/7] Uniswap pool created:", liquidityPool); } else { - console.log("\n[3/6] Using existing pool:", liquidityPool); + console.log("\n[3/7] Using existing pool:", liquidityPool); } pool = IUniswapV3Pool(liquidityPool); @@ -91,20 +103,70 @@ contract DeployLocal is Script { bytes memory params = abi.encodeWithSignature("initialize(address,address)", address(kraiken), address(stake)); ERC1967Proxy proxy = new ERC1967Proxy(address(optimizerImpl), params); address optimizerAddress = address(proxy); - console.log("\n[4/6] Optimizer deployed:", optimizerAddress); + console.log("\n[4/7] Optimizer deployed:", optimizerAddress); // Deploy LiquidityManager liquidityManager = new LiquidityManager(v3Factory, weth, address(kraiken), optimizerAddress); - console.log("\n[5/6] LiquidityManager deployed:", address(liquidityManager)); + console.log("\n[5/7] LiquidityManager deployed:", address(liquidityManager)); // Configure contracts - liquidityManager.setFeeDestination(feeDest); - console.log(" Fee destination set"); - kraiken.setLiquidityManager(address(liquidityManager)); console.log(" LiquidityManager set in Kraiken"); - console.log("\n[6/6] Configuration complete"); + console.log("\n[6/7] Configuration complete"); + + // ===================================================================== + // [7/7] VWAP Bootstrap -> seed trade during deployment + // + // 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. + // + // Sequence: + // 1. Temporarily make sender the feeDestination (deployer can do this + // because setFeeDestination is gated on deployer, not feeDestination). + // This allows sender to call setRecenterAccess. + // 2. Fund LM with SEED_LM_ETH and call recenter() -> places thin initial + // positions; no fees collected yet, so cumulativeVolume stays 0. + // 3. Execute seed buy via SeedSwapper -> generates a non-zero WETH fee + // in the anchor position and moves the tick >400 (minimum amplitude). + // 4. Call recenter() again -> cumulativeVolume==0 triggers the bootstrap + // path (shouldRecordVWAP=true); ethFee>0 → _recordVolumeAndPrice fires + // → cumulativeVolume>0. VWAP is now anchored to the real launch price. + // 5. Revoke recenterAccess and restore the real feeDestination. + // ===================================================================== + console.log("\n[7/7] Bootstrapping VWAP with seed trade..."); + + // Step 1: Grant deployer temporary feeDestination role to enable setRecenterAccess. + liquidityManager.setFeeDestination(sender); + liquidityManager.setRecenterAccess(sender); + console.log(" Temporary recenterAccess granted to deployer"); + + // Step 2: Fund LM and place initial bootstrap positions. + (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"); + + // 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 4: Second recenter records VWAP (bootstrap path + ethFee > 0). + liquidityManager.recenter(); + require(liquidityManager.cumulativeVolume() > 0, "VWAP bootstrap failed: cumulativeVolume is 0"); + console.log(" Second recenter complete -> VWAP bootstrapped"); + console.log(" cumulativeVolume:", liquidityManager.cumulativeVolume()); + console.log(" VWAP (X96):", liquidityManager.getVWAP()); + + // Step 5: Clean up -> revoke temporary access and set the real feeDestination. + liquidityManager.revokeRecenterAccess(); + liquidityManager.setFeeDestination(feeDest); + console.log(" recenterAccess revoked, feeDestination restored to", feeDest); // Print deployment summary console.log("\n=== Deployment Summary ==="); @@ -115,10 +177,12 @@ contract DeployLocal is Script { console.log("Optimizer:", optimizerAddress); console.log("\n=== Next Steps ==="); - console.log("1. Fund LiquidityManager with ETH:"); - console.log(" cast send", address(liquidityManager), "--value 0.1ether"); - console.log("2. Call recenter to initialize positions:"); - console.log(" cast send", address(liquidityManager), "\"recenter()\""); + console.log("VWAP is already bootstrapped. To go live:"); + console.log("1. Fund LiquidityManager with operational ETH (current balance includes seed):"); + console.log(" cast send", address(liquidityManager), "--value 10ether"); + console.log("2. Grant recenterAccess to txnBot (call from feeDestination):"); + console.log(" cast send", address(liquidityManager), "\"setRecenterAccess(address)\" "); + console.log("3. txnBot can now call recenter() to rebalance positions."); vm.stopBroadcast(); } diff --git a/onchain/test/VWAPFloorProtection.t.sol b/onchain/test/VWAPFloorProtection.t.sol index 20d099f..95bbc22 100644 --- a/onchain/test/VWAPFloorProtection.t.sol +++ b/onchain/test/VWAPFloorProtection.t.sol @@ -212,6 +212,42 @@ contract VWAPFloorProtectionTest is UniSwapHelper { } } + // ========================================================================= + // Deployment bootstrap: seed trade seeds VWAP before protocol goes live + // ========================================================================= + + /** + * @notice Verifies the deployment bootstrap sequence from DeployLocal.sol / DeployBase.sol. + * + * The deploy scripts execute: + * 1. First recenter — places bootstrap positions; no fees, cumulativeVolume stays 0. + * 2. Seed buy — small swap generates a non-zero WETH fee in the anchor position + * and moves the tick >400 (amplitude gate for the second recenter). + * 3. Second recenter — cumulativeVolume==0 path fires (shouldRecordVWAP=true) and + * ethFee>0, so _recordVolumeAndPrice is called. + * + * After step 3, cumulativeVolume>0 and the bootstrap path is permanently closed to + * external users. This test mirrors that sequence and asserts the invariant holds. + */ + function test_vwapBootstrappedBySeedTrade() public { + // Step 1: Initial recenter — places positions, no fees yet. + vm.prank(RECENTER_CALLER); + lm.recenter(); + assertEq(lm.cumulativeVolume(), 0, "no fees before seed trade: cumulativeVolume must be 0"); + + // Step 2: Seed buy — enough to move the tick >400 (amplitude gate) and generate fee. + // 25 ether against a 100 ETH LM pool reliably satisfies the amplitude check + // (same amount used across other bootstrap tests in this file). + buyRaw(25 ether); + + // Step 3: Second recenter — bootstrap path records VWAP. + vm.prank(RECENTER_CALLER); + lm.recenter(); + + assertGt(lm.cumulativeVolume(), 0, "seed trade must bootstrap cumulativeVolume to non-zero"); + assertGt(lm.getVWAP(), 0, "seed trade must anchor VWAP to the real launch price"); + } + // ========================================================================= // getLiquidityManager override for UniSwapHelper boundary helpers // ========================================================================= diff --git a/scripts/bootstrap-common.sh b/scripts/bootstrap-common.sh index 3e42594..a67df08 100755 --- a/scripts/bootstrap-common.sh +++ b/scripts/bootstrap-common.sh @@ -53,7 +53,17 @@ wait_for_rpc() { run_forge_script() { bootstrap_log "Deploying contracts to fork" pushd "$ONCHAIN_DIR" >/dev/null - forge script script/DeployLocal.sol --fork-url "$ANVIL_RPC" --broadcast >>"$LOG_FILE" 2>&1 + local _forge_log + _forge_log="$(mktemp)" + if ! forge script script/DeployLocal.sol --tc DeployLocal --fork-url "$ANVIL_RPC" --broadcast >"$_forge_log" 2>&1; then + bootstrap_log "forge script FAILED — output:" + cat "$_forge_log" >&2 + rm -f "$_forge_log" + popd >/dev/null + return 1 + fi + cat "$_forge_log" >>"$LOG_FILE" 2>/dev/null || true + rm -f "$_forge_log" popd >/dev/null } @@ -113,6 +123,24 @@ call_recenter() { recenter_pk="$TXNBOT_PRIVATE_KEY" recenter_addr="$TXNBOT_ADDRESS" fi + + # If the deploy script already bootstrapped VWAP (cumulativeVolume > 0), positions + # are in place at the post-seed-buy tick. Calling recenter() now would fail with + # "amplitude not reached" because currentTick == anchorCenterTick. Skip it. + local cumvol + cumvol="$(cast call --rpc-url "$ANVIL_RPC" \ + "$LIQUIDITY_MANAGER" "cumulativeVolume()(uint256)" 2>/dev/null || echo "0")" + # cast call with a typed (uint256) selector returns a plain decimal string for + # non-zero values (e.g. "140734553600000") and "0" for zero. A simple != "0" + # check is sufficient; note that the output may include a scientific-notation + # annotation (e.g. "140734553600000 [1.407e14]") which is also != "0", so we + # do NOT attempt to parse it further with cast to-dec (which would fail on the + # annotation and incorrectly fall back to "0"). + if [[ "$cumvol" != "0" && -n "$cumvol" ]]; then + bootstrap_log "VWAP already bootstrapped by deploy script (cumulativeVolume=$cumvol) -- skipping initial recenter" + return 0 + fi + bootstrap_log "Calling recenter() via $recenter_addr" cast send --rpc-url "$ANVIL_RPC" --private-key "$recenter_pk" \ "$LIQUIDITY_MANAGER" "recenter()" >>"$LOG_FILE" 2>&1 diff --git a/tools/deploy-optimizer.sh b/tools/deploy-optimizer.sh index 2ba9874..bb70adf 100755 --- a/tools/deploy-optimizer.sh +++ b/tools/deploy-optimizer.sh @@ -200,7 +200,7 @@ else info "No OPTIMIZER_PROXY set — deploying fresh local stack via DeployLocal.sol" ( cd "$ONCHAIN_DIR" - forge script script/DeployLocal.sol \ + forge script script/DeployLocal.sol --tc DeployLocal \ --rpc-url "$RPC_URL" \ --broadcast 2>&1 | tee /tmp/deploy-local-output.txt ) @@ -237,6 +237,33 @@ PYEOF fail "Could not determine OPTIMIZER_PROXY from fresh deployment. Set OPTIMIZER_PROXY manually." fi info "Fresh stack deployed. Optimizer proxy: $OPTIMIZER_PROXY" + + # Verify that the seed trade bootstrapped VWAP during deployment. + # DeployLocal.sol runs a first recenter + seed buy + second recenter so that + # cumulativeVolume>0 before any user can interact with the protocol. + LM_ADDR="" + if [ -f "$BROADCAST_JSON" ]; then + LM_ADDR="$(python3 - "$BROADCAST_JSON" <<'PYEOF' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +for tx in data.get('transactions', []): + if (tx.get('contractName') or '').lower() == 'liquiditymanager': + print(tx.get('contractAddress', '')) + break +PYEOF +)" + fi + if [ -n "$LM_ADDR" ]; then + CUMVOL_HEX="$(cast call "$LM_ADDR" "cumulativeVolume()(uint256)" \ + --rpc-url "$RPC_URL" 2>/dev/null || echo "0x0")" + CUMVOL="$(decode_uint "$CUMVOL_HEX")" + if [ "$CUMVOL" -gt 0 ]; then + success "VWAP bootstrapped: LiquidityManager.cumulativeVolume=$CUMVOL" + else + fail "VWAP not bootstrapped: cumulativeVolume=0 — seed trade may have failed" + fi + fi fi fi