diff --git a/containers/bootstrap.sh b/containers/bootstrap.sh index 945df7a..0160018 100755 --- a/containers/bootstrap.sh +++ b/containers/bootstrap.sh @@ -135,9 +135,8 @@ main() { run_forge_script extract_addresses write_contracts_env + bootstrap_vwap fund_liquidity_manager - grant_recenter_access - call_recenter seed_application_state write_deployments_json write_ponder_env diff --git a/onchain/script/BootstrapVWAPPhase2.s.sol b/onchain/script/BootstrapVWAPPhase2.s.sol new file mode 100644 index 0000000..dcd7f0d --- /dev/null +++ b/onchain/script/BootstrapVWAPPhase2.s.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import { LiquidityManager } from "../src/LiquidityManager.sol"; +import "forge-std/Script.sol"; + +/** + * @title BootstrapVWAPPhase2 + * @notice Second phase of the VWAP bootstrap for Base mainnet deployments. + * + * Run this script >= 60 seconds after DeployBase (or DeployBaseMainnet/DeployBaseSepolia) + * finishes. The first recenter() sets lastRecenterTime; the 60-second cooldown must + * elapse before this second recenter() can succeed. + * + * What this does: + * - Calls liquidityManager.recenter() a second time. + * - At this point cumulativeVolume == 0 (bootstrap path) and the seed buy has + * generated ethFee > 0, so recenter() records the VWAP anchor. + * - Asserts cumulativeVolume > 0 to confirm bootstrap success. + * + * Usage: + * export LM_ADDRESS= + * forge script script/BootstrapVWAPPhase2.s.sol --tc BootstrapVWAPPhase2 \ + * --fork-url $BASE_RPC --broadcast + */ +contract BootstrapVWAPPhase2 is Script { + function run() public { + address lmAddress = vm.envAddress("LM_ADDRESS"); + LiquidityManager lm = LiquidityManager(payable(lmAddress)); + + string memory seedPhrase = vm.readFile(".secret"); + uint256 privateKey = vm.deriveKey(seedPhrase, 0); + vm.startBroadcast(privateKey); + + console.log("Running VWAP bootstrap phase 2 on LiquidityManager:", lmAddress); + + lm.recenter(); + + uint256 cumVol = lm.cumulativeVolume(); + require(cumVol > 0, "VWAP bootstrap failed: cumulativeVolume is still 0"); + console.log("VWAP bootstrapped successfully. cumulativeVolume:", cumVol); + + vm.stopBroadcast(); + } +} diff --git a/onchain/script/DeployBase.sol b/onchain/script/DeployBase.sol index de9065c..5b524ef 100644 --- a/onchain/script/DeployBase.sol +++ b/onchain/script/DeployBase.sol @@ -108,32 +108,38 @@ contract DeployBase is Script { // 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). // ===================================================================== console.log("\nBootstrapping VWAP with seed trade..."); - // Step 1: Temporarily set deployer as feeDestination to call setRecenterAccess. - liquidityManager.setFeeDestination(sender); - liquidityManager.setRecenterAccess(sender); + // Step 1: Set the real feeDestination before any recenter. + liquidityManager.setFeeDestination(feeDest); + 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. (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("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); + // Cannot be called in the same Forge broadcast as Step 2 — recenter() enforces a + // 60-second cooldown and there is no time-warp mechanism in a live broadcast. + // Run BootstrapVWAPPhase2.s.sol at least 60 seconds after this script completes. console.log("\n=== Deployment Complete ==="); console.log("Kraiken:", address(kraiken)); @@ -142,9 +148,11 @@ contract DeployBase is Script { console.log("LiquidityManager:", address(liquidityManager)); console.log("Optimizer:", optimizerAddress); console.log("\nPost-deploy steps:"); - 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. txnBot can now call recenter()"); + 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(" 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."); vm.stopBroadcast(); } diff --git a/onchain/script/DeployLocal.sol b/onchain/script/DeployLocal.sol index d294974..ced4bed 100644 --- a/onchain/script/DeployLocal.sol +++ b/onchain/script/DeployLocal.sol @@ -11,7 +11,6 @@ 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 @@ -28,17 +27,6 @@ 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; @@ -60,7 +48,7 @@ contract DeployLocal is Script { // Deploy Kraiken token kraiken = new Kraiken("Kraiken", "KRK"); - console.log("\n[1/7] Kraiken deployed:", address(kraiken)); + console.log("\n[1/6] Kraiken deployed:", address(kraiken)); // Determine token ordering token0isWeth = address(weth) < address(kraiken); @@ -68,7 +56,7 @@ contract DeployLocal is Script { // Deploy Stake contract stake = new Stake(address(kraiken), feeDest); - console.log("\n[2/7] Stake deployed:", address(stake)); + console.log("\n[2/6] Stake deployed:", address(stake)); // Set staking pool in Kraiken kraiken.setStakingPool(address(stake)); @@ -79,9 +67,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/7] Uniswap pool created:", liquidityPool); + console.log("\n[3/6] Uniswap pool created:", liquidityPool); } else { - console.log("\n[3/7] Using existing pool:", liquidityPool); + console.log("\n[3/6] Using existing pool:", liquidityPool); } pool = IUniswapV3Pool(liquidityPool); @@ -103,70 +91,22 @@ 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/7] Optimizer deployed:", optimizerAddress); + console.log("\n[4/6] Optimizer deployed:", optimizerAddress); // Deploy LiquidityManager liquidityManager = new LiquidityManager(v3Factory, weth, address(kraiken), optimizerAddress); - console.log("\n[5/7] LiquidityManager deployed:", address(liquidityManager)); + console.log("\n[5/6] LiquidityManager deployed:", address(liquidityManager)); // Configure contracts kraiken.setLiquidityManager(address(liquidityManager)); console.log(" LiquidityManager set in Kraiken"); - 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(); + // Set the real feeDestination. liquidityManager.setFeeDestination(feeDest); - console.log(" recenterAccess revoked, feeDestination restored to", feeDest); + + console.log("\n[6/6] Configuration complete"); + console.log(" feeDestination set to", feeDest); + console.log(" VWAP bootstrap will be performed by the bootstrap script"); // Print deployment summary console.log("\n=== Deployment Summary ==="); @@ -177,12 +117,11 @@ contract DeployLocal is Script { console.log("Optimizer:", optimizerAddress); console.log("\n=== Next Steps ==="); - console.log("VWAP is already bootstrapped. To go live:"); - console.log("1. Fund LiquidityManager with operational ETH (current balance includes seed):"); + console.log("1. bootstrap-common.sh bootstrap_vwap() advances chain time and seeds VWAP."); + console.log("2. Fund LiquidityManager with operational ETH:"); 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."); + console.log("3. recenter() is permissionless - any address (e.g. txnBot) can call it."); + console.log(" TWAP manipulation protection is always enforced (no bypass path)."); vm.stopBroadcast(); } diff --git a/onchain/script/backtesting/BacktestRunner.s.sol b/onchain/script/backtesting/BacktestRunner.s.sol index 0f5f205..9da45d6 100644 --- a/onchain/script/backtesting/BacktestRunner.s.sol +++ b/onchain/script/backtesting/BacktestRunner.s.sol @@ -143,14 +143,13 @@ contract BacktestRunner is Script { // ------------------------------------------------------------------ KrAIkenSystem memory sys = KrAIkenDeployer.deploy(address(sp.factory), address(mockWeth), address(krk), sender, initialCapital); - // Deploy StrategyExecutor and grant it recenter access on the LM. - // recenterAccess bypasses TWAP stability check and cooldown — correct - // for simulation where vm.warp drives time, not a real oracle. - // sender == feeDestination, so the onlyFeeDestination guard is satisfied. + // Deploy StrategyExecutor — recenter() is now permissionless, so no + // access grant is needed. StrategyExecutor.maybeRecenter() calls + // recenter() via try/catch and logs "SKIP" on cooldown/TWAP failures. + // vm.warp in EventReplayer drives time so TWAP and cooldown pass. bool token0isWeth = sp.token0 == address(mockWeth); StrategyExecutor executor = new StrategyExecutor(sys.lm, IERC20(address(mockWeth)), IERC20(address(krk)), sender, recenterInterval, sp.pool, token0isWeth); - sys.lm.setRecenterAccess(address(executor)); // Deploy baseline strategies and initialize with the same capital as KrAIken. BaselineStrategies baselines = diff --git a/onchain/script/backtesting/StrategyExecutor.sol b/onchain/script/backtesting/StrategyExecutor.sol index 14708f5..9b3dcc6 100644 --- a/onchain/script/backtesting/StrategyExecutor.sol +++ b/onchain/script/backtesting/StrategyExecutor.sol @@ -24,9 +24,9 @@ import { console2 } from "forge-std/console2.sol"; * notified on every block (for time-in-range) and on each successful recenter * (for position lifecycle and fee/IL accounting). * - * Access model: StrategyExecutor must be set as recenterAccess on the LM so that - * the cooldown and TWAP price-stability checks are bypassed in the simulation - * (vm.warp advances simulated time, not real oracle state). + * Access model: recenter() is permissionless — no special access grant is required. + * EventReplayer advances block.timestamp via vm.warp, so the 60-second cooldown and + * the 300-second TWAP window pass normally during simulation. * * TODO(#319): The negligible-impact assumption means we replay historical events * as-is without accounting for KrAIken's own liquidity affecting swap outcomes. diff --git a/onchain/src/LiquidityManager.sol b/onchain/src/LiquidityManager.sol index e1fbe84..0daccf9 100644 --- a/onchain/src/LiquidityManager.sol +++ b/onchain/src/LiquidityManager.sol @@ -53,16 +53,15 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Access control and fee management address private immutable deployer; - address public recenterAccess; address public feeDestination; bool public feeDestinationLocked; /// @notice Last recenter tick — used to determine net trade direction between recenters int24 public lastRecenterTick; - /// @notice Last recenter timestamp — rate limits open recenters. + /// @notice Last recenter timestamp — rate limits recenters. uint256 public lastRecenterTime; - /// @notice Minimum seconds between open recenters (when recenterAccess is unset) + /// @notice Minimum seconds between recenters uint256 internal constant MIN_RECENTER_INTERVAL = 60; /// @notice Target observation cardinality requested from the pool during construction uint16 internal constant ORACLE_CARDINALITY = 100; @@ -73,12 +72,6 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Custom errors error ZeroAddressInSetter(); - /// @notice Access control modifier - modifier onlyFeeDestination() { - require(msg.sender == address(feeDestination), "only callable by feeDestination"); - _; - } - /// @notice Constructor initializes all contract references and pool configuration /// @param _factory The address of the Uniswap V3 factory /// @param _WETH9 The address of the WETH contract @@ -146,28 +139,17 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { } } - /// @notice Sets recenter access for testing/emergency purposes - /// @param addr Address to grant recenter access - function setRecenterAccess(address addr) external onlyFeeDestination { - recenterAccess = addr; - } - - /// @notice Revokes recenter access - function revokeRecenterAccess() external onlyFeeDestination { - recenterAccess = address(0); - } - /// @notice Adjusts liquidity positions in response to price movements. + /// Callable by anyone. Always enforces cooldown and TWAP price stability. /// This function either completes a full recenter (removing all positions, /// recording VWAP where applicable, and redeploying liquidity) or reverts — /// it never returns silently without acting. /// /// @dev Revert conditions (no silent false return for failure): - /// - "access denied" — recenterAccess is set and caller is not that address - /// - "recenter cooldown" — recenterAccess is unset and MIN_RECENTER_INTERVAL has not elapsed - /// - "price deviated from oracle" — recenterAccess is unset and price is outside TWAP bounds - /// - "amplitude not reached." — anchor position exists but price has not moved far enough - /// from the anchor centre to warrant repositioning + /// - "recenter cooldown" — MIN_RECENTER_INTERVAL has not elapsed since last recenter + /// - "price deviated from oracle" — price is outside TWAP bounds (manipulation guard) + /// - "amplitude not reached." — anchor position exists but price has not moved far enough + /// from the anchor centre to warrant repositioning /// /// @return isUp True if the KRK price in ETH rose since the last recenter /// (buy event / net ETH inflow), regardless of token0/token1 ordering. @@ -177,13 +159,9 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { function recenter() external returns (bool isUp) { (, int24 currentTick,,,,,) = pool.slot0(); - // Validate access and price stability - if (recenterAccess != address(0)) { - require(msg.sender == recenterAccess, "access denied"); - } else { - require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown"); - require(_isPriceStable(currentTick), "price deviated from oracle"); - } + // Always enforce cooldown and TWAP price stability — no bypass path + require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown"); + require(_isPriceStable(currentTick), "price deviated from oracle"); lastRecenterTime = block.timestamp; // Check if price movement is sufficient for recentering diff --git a/onchain/test/EthScarcityAbundance.t.sol b/onchain/test/EthScarcityAbundance.t.sol index e540334..95f126e 100644 --- a/onchain/test/EthScarcityAbundance.t.sol +++ b/onchain/test/EthScarcityAbundance.t.sol @@ -41,7 +41,7 @@ contract EthScarcityAbundance is Test { // Default params: CI=50%, AS=50%, AW=50, DD=50% optimizer = new ConfigurableOptimizer(5e17, 5e17, 50, 5e17); - (factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer)); + (factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, address(optimizer)); swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true); @@ -231,7 +231,7 @@ contract EthScarcityAbundance is Test { // Bull optimizer: high anchorShare, wide anchor, deep discovery ConfigurableOptimizer bullOpt = new ConfigurableOptimizer(3e17, 8e17, 80, 8e17); - (,,,,, LiquidityManager bullLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bullOpt)); + (,,,,, LiquidityManager bullLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, address(bullOpt)); vm.deal(address(bullLm), 200 ether); vm.prank(address(bullLm)); @@ -252,7 +252,7 @@ contract EthScarcityAbundance is Test { // Bear optimizer: low anchorShare, moderate anchor, thin discovery ConfigurableOptimizer bearOpt = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17); - (,,,,, LiquidityManager bearLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bearOpt)); + (,,,,, LiquidityManager bearLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, address(bearOpt)); vm.deal(address(bearLm), 200 ether); vm.prank(address(bearLm)); diff --git a/onchain/test/FitnessEvaluator.t.sol b/onchain/test/FitnessEvaluator.t.sol index 3d777c7..9434163 100644 --- a/onchain/test/FitnessEvaluator.t.sol +++ b/onchain/test/FitnessEvaluator.t.sol @@ -165,7 +165,7 @@ contract FitnessEvaluator is Test { /// @dev Account 8 — adversary (10 000 ETH in Anvil; funded via vm.deal here) uint256 internal constant ADV_PK = 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97; - /// @dev Account 2 — recenter caller (granted recenterAccess in bootstrap) + /// @dev Account 2 — recenter caller (recenter() is now permissionless) uint256 internal constant RECENTER_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; // ─── Runtime state ──────────────────────────────────────────────────────── @@ -250,7 +250,7 @@ contract FitnessEvaluator is Test { bytes32 ERC1967_IMPL = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; vm.store(optProxy, ERC1967_IMPL, bytes32(uint256(uint160(IMPL_SLOT)))); - // Bootstrap: fund LM, set recenterAccess, initial recenter. + // Bootstrap: fund LM, initial recenter. if (!_bootstrap()) { console.log(string.concat('{"candidate_id":"', candidateId, '","fitness":0,"error":"bootstrap_failed"}')); continue; @@ -369,18 +369,14 @@ contract FitnessEvaluator is Test { * @notice Bootstrap LM state for a candidate evaluation (mirrors fitness.sh bootstrap). * * Steps (same order as fitness.sh): - * a. Grant recenterAccess to recenterAddr (impersonate feeDestination). - * b. Fund adversary account and wrap ETH → WETH. - * c. Transfer 1000 WETH to LM. - * d. Wrap 9000 WETH for adversary trades + set approvals. - * e. Initial recenter (succeeds immediately: recenterAccess set, no ANCHOR liquidity yet). + * a. Fund adversary account and wrap ETH → WETH. + * b. Transfer 1000 WETH to LM. + * c. Wrap 9000 WETH for adversary trades + set approvals. + * d. Initial recenter (callable by anyone: cooldown passes because block.timestamp on a + * Base fork is a large value >> 60; TWAP passes because the pool has existing history). */ function _bootstrap() internal returns (bool) { - // a. Grant recenterAccess (feeDestination call, no ETH needed with gas_price=0). - vm.prank(FEE_DEST); - LiquidityManager(payable(lmAddr)).setRecenterAccess(recenterAddr); - - // b. Fund adversary with ETH. + // a. Fund adversary with ETH. vm.deal(advAddr, 10_000 ether); // c. Wrap 1000 ETH → WETH and send to LM. @@ -399,8 +395,8 @@ contract FitnessEvaluator is Test { IERC20(krkAddr).approve(NPM_ADDR, type(uint256).max); vm.stopPrank(); - // e. Initial recenter: no ANCHOR position exists yet so amplitude check is skipped; - // recenterAccess is set so TWAP stability check is also skipped. + // d. Initial recenter: no ANCHOR position exists yet so amplitude check is skipped. + // Cooldown passes (Base fork timestamp >> 60). TWAP passes (existing pool history). // If all retries fail, revert with a clear message — silent failure would make every // candidate score identically (all lm_eth_total = free WETH only, no positions). bool recentered = false; diff --git a/onchain/test/FuzzingAnalyzerBugs.t.sol b/onchain/test/FuzzingAnalyzerBugs.t.sol index e60772e..058741b 100644 --- a/onchain/test/FuzzingAnalyzerBugs.t.sol +++ b/onchain/test/FuzzingAnalyzerBugs.t.sol @@ -37,7 +37,7 @@ contract FuzzingAnalyzerBugs is Test { // Bear market params: CI=0.8e18, AS=0.1e18, AW=40, DD=0.2e18 optimizer = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17); - (factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer)); + (factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, address(optimizer)); swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true); diff --git a/onchain/test/LiquidityManager.t.sol b/onchain/test/LiquidityManager.t.sol index dd9d4ad..174c472 100644 --- a/onchain/test/LiquidityManager.t.sol +++ b/onchain/test/LiquidityManager.t.sol @@ -148,7 +148,7 @@ contract LiquidityManagerTest is UniSwapHelper { LiquidityManager _lm, Optimizer _optimizer, bool _token0isWeth - ) = testEnv.setupEnvironment(token0shouldBeWeth, RECENTER_CALLER); + ) = testEnv.setupEnvironment(token0shouldBeWeth); // Assign to state variables factory = _factory; @@ -406,12 +406,6 @@ contract LiquidityManagerTest is UniSwapHelper { _skipAutoSetup = true; } - /// @notice Grant recenter access for testing (commonly needed) - function _grantRecenterAccess() internal { - vm.prank(feeDestination); - lm.setRecenterAccess(RECENTER_CALLER); - } - /// @notice Setup with custom parameters but standard flow function _setupCustom(bool token0IsWeth, uint256 accountBalance) internal { disableAutoSetup(); @@ -450,10 +444,6 @@ contract LiquidityManagerTest is UniSwapHelper { vm.prank(account); weth.deposit{ value: 15_000 ether }(); - // Grant recenter access - vm.prank(feeDestination); - lm.setRecenterAccess(RECENTER_CALLER); - // Setup approvals without creating blocking positions vm.startPrank(account); weth.approve(address(lm), type(uint256).max); @@ -947,58 +937,25 @@ contract LiquidityManagerTest is UniSwapHelper { } // ========================================================= - // COVERAGE TESTS: onlyFeeDestination, revokeRecenterAccess, - // open recenter path, VWAP else branch, + // COVERAGE TESTS: cooldown check, TWAP oracle path, VWAP else branch, // optimizer fallback, _getKraikenToken/_getWethToken // ========================================================= /** - * @notice Calling an onlyFeeDestination function from a non-fee address must revert - */ - function testOnlyFeeDestinationReverts() public { - address nonFee = makeAddr("notFeeDestination"); - vm.expectRevert("only callable by feeDestination"); - vm.prank(nonFee); - lm.setRecenterAccess(nonFee); - } - - /** - * @notice feeDestination can revoke recenter access (covers revokeRecenterAccess body) - */ - function testRevokeRecenterAccess() public { - assertEq(lm.recenterAccess(), RECENTER_CALLER, "precondition: access should be set"); - - vm.prank(feeDestination); - lm.revokeRecenterAccess(); - - assertEq(lm.recenterAccess(), address(0), "recenterAccess should be revoked"); - } - - /** - * @notice Open recenter (no access restriction) must fail with cooldown if called too soon + * @notice recenter() must fail with cooldown if called too soon after the last recenter */ function testOpenRecenterCooldown() public { - vm.prank(feeDestination); - lm.revokeRecenterAccess(); - // Immediately try to recenter without waiting — should hit cooldown check vm.expectRevert("recenter cooldown"); lm.recenter(); } /** - * @notice After cooldown, open recenter calls _isPriceStable (covering _getPool) then - * hits amplitude check (covers the open-recenter else branch, lines 141-142, 265-266) - * @dev PriceOracle._isPriceStable has a 60,000-second fallback interval. - * setUp warps ~18,000s so the pool's history is only ~18,000s. - * We warp an additional 61,000s so pool history > 60,000s for the fallback to succeed. + * @notice After cooldown, recenter() calls _isPriceStable (covering _getPool) then + * hits amplitude check when price has not moved since last recenter */ function testOpenRecenterOracleCheck() public { - vm.prank(feeDestination); - lm.revokeRecenterAccess(); - - // Warp enough seconds so pool.observe([300,0]) and its fallback ([60000,0]) both succeed. - // Pool was initialized at timestamp 1; after setUp + this warp: ~79,001s of history. + // Warp enough seconds for cooldown + TWAP window (300s). vm.warp(block.timestamp + 61_000); // _isPriceStable (→ _getPool) is called; price unchanged → stable. @@ -1072,7 +1029,7 @@ contract LiquidityManagerTest is UniSwapHelper { function testOptimizerFallback() public { RevertingOptimizer revertingOpt = new RevertingOptimizer(); TestEnvironment env = new TestEnvironment(feeDestination); - (,,,,, LiquidityManager _lm,,) = env.setupEnvironmentWithOptimizer(DEFAULT_TOKEN0_IS_WETH, RECENTER_CALLER, address(revertingOpt)); + (,,,,, LiquidityManager _lm,,) = env.setupEnvironmentWithOptimizer(DEFAULT_TOKEN0_IS_WETH, address(revertingOpt)); // Recenter uses the fallback params from the catch block vm.prank(RECENTER_CALLER); @@ -1108,7 +1065,7 @@ contract LiquidityManagerTest is UniSwapHelper { LiquidityManager _lm, Optimizer _optimizer, bool _token0isWeth - ) = selfFeeEnv.setupEnvironmentWithSelfFeeDestination(DEFAULT_TOKEN0_IS_WETH, RECENTER_CALLER); + ) = selfFeeEnv.setupEnvironmentWithSelfFeeDestination(DEFAULT_TOKEN0_IS_WETH); // Wire state variables used by buy/sell/recenter helpers factory = _factory; @@ -1133,6 +1090,9 @@ contract LiquidityManagerTest is UniSwapHelper { // Move price up with a buy so the second recenter satisfies amplitude requirement buyRaw(10 ether); + // Warp past cooldown interval; also lets TWAP settle at the post-buy price. + vm.warp(block.timestamp + 301); + // Second recenter: _scrapePositions() burns positions and collects principal KRK // into the LM's balance. _setPositions() then calls _getOutstandingSupply(). // Without the fix: outstandingSupply() already excludes balanceOf(lm), and @@ -1183,7 +1143,6 @@ contract LiquidityManagerTest is UniSwapHelper { , ) = clampTestEnv.setupEnvironmentWithOptimizer( DEFAULT_TOKEN0_IS_WETH, - RECENTER_CALLER, address(highWidthOptimizer) ); diff --git a/onchain/test/ReplayProfitableScenario.t.sol b/onchain/test/ReplayProfitableScenario.t.sol index 1b9c952..add766e 100644 --- a/onchain/test/ReplayProfitableScenario.t.sol +++ b/onchain/test/ReplayProfitableScenario.t.sol @@ -36,7 +36,7 @@ contract ReplayProfitableScenario is Test { BullMarketOptimizer optimizer = new BullMarketOptimizer(); // Use seed 1 setup (odd seed = false for first param) - (, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(false, feeDestination, address(optimizer)); + (, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithOptimizer(false, address(optimizer)); // Fund exactly as in the recorded scenario vm.deal(address(lm), 200 ether); diff --git a/onchain/test/SupplyCorruption.t.sol b/onchain/test/SupplyCorruption.t.sol index 113ebb2..3117305 100644 --- a/onchain/test/SupplyCorruption.t.sol +++ b/onchain/test/SupplyCorruption.t.sol @@ -40,7 +40,7 @@ contract SupplyCorruptionTest is UniSwapHelper { LiquidityManager _lm, Optimizer _optimizer, bool _token0isWeth - ) = testEnv.setupEnvironment(false, RECENTER_CALLER); + ) = testEnv.setupEnvironment(false); factory = _factory; pool = _pool; @@ -83,6 +83,7 @@ contract SupplyCorruptionTest is UniSwapHelper { performSwap(5 ether, true); console.log("Performed 5 ETH swap to move price"); + vm.warp(block.timestamp + 301); // TWAP catches up to post-swap price; cooldown passes // Call recenter vm.prank(RECENTER_CALLER); @@ -126,6 +127,7 @@ contract SupplyCorruptionTest is UniSwapHelper { console.log("Initial supply:", initialTotalSupply); // Perform multiple recenter cycles + uint256 ts = block.timestamp; // track time explicitly to avoid Forge block.timestamp reset for (uint256 i = 0; i < 3; i++) { // Swap to move price vm.deal(account, 2 ether); @@ -133,6 +135,8 @@ contract SupplyCorruptionTest is UniSwapHelper { weth.deposit{ value: 2 ether }(); performSwap(2 ether, true); + ts += 301; // TWAP catches up; cooldown passes + vm.warp(ts); vm.prank(RECENTER_CALLER); lm.recenter(); diff --git a/onchain/test/VWAPFloorProtection.t.sol b/onchain/test/VWAPFloorProtection.t.sol index 95bbc22..f0ca3a5 100644 --- a/onchain/test/VWAPFloorProtection.t.sol +++ b/onchain/test/VWAPFloorProtection.t.sol @@ -34,7 +34,7 @@ contract VWAPFloorProtectionTest is UniSwapHelper { function setUp() public { testEnv = new TestEnvironment(feeDestination); (,pool, weth, harberg, , lm, , token0isWeth) = - testEnv.setupEnvironment(false, RECENTER_CALLER); + testEnv.setupEnvironment(false); vm.deal(address(lm), LM_ETH); @@ -69,6 +69,7 @@ contract VWAPFloorProtectionTest is UniSwapHelper { // ---- step 2: first buy + recenter → bootstrap ---- buyRaw(25 ether); // push price up enough to satisfy amplitude check + vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes vm.prank(RECENTER_CALLER); lm.recenter(); // cumulativeVolume == 0 → shouldRecordVWAP = true (bootstrap path) @@ -78,8 +79,11 @@ contract VWAPFloorProtectionTest is UniSwapHelper { // ---- step 3: continued buy-only cycles ---- uint256 successfulBuyCycles; + uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset for (uint256 i = 0; i < 10; i++) { buyRaw(25 ether); + ts += 301; // TWAP catches up; cooldown passes + vm.warp(ts); vm.prank(RECENTER_CALLER); // Recenter may fail if amplitude isn't reached; that's fine. try lm.recenter() { @@ -114,12 +118,16 @@ contract VWAPFloorProtectionTest is UniSwapHelper { // Bootstrap via first buy-recenter buyRaw(25 ether); + vm.warp(block.timestamp + 301); // TWAP catches up; cooldown passes vm.prank(RECENTER_CALLER); lm.recenter(); // Run several buy cycles + uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset for (uint256 i = 0; i < 6; i++) { buyRaw(25 ether); + ts += 301; // TWAP catches up; cooldown passes + vm.warp(ts); vm.prank(RECENTER_CALLER); try lm.recenter() { } catch { } } @@ -160,6 +168,7 @@ contract VWAPFloorProtectionTest is UniSwapHelper { assertEq(lm.cumulativeVolume(), 0, "no VWAP data before first fees"); buyRaw(25 ether); + vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes vm.prank(RECENTER_CALLER); lm.recenter(); @@ -188,7 +197,10 @@ contract VWAPFloorProtectionTest is UniSwapHelper { vm.prank(RECENTER_CALLER); lm.recenter(); + uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset buyRaw(25 ether); + ts += 301; // TWAP catches up to post-buy price; cooldown passes + vm.warp(ts); vm.prank(RECENTER_CALLER); lm.recenter(); @@ -199,6 +211,8 @@ contract VWAPFloorProtectionTest is UniSwapHelper { } // Recenter with price now lower (sell direction) — must not revert + ts += 301; // TWAP catches up to post-sell price; cooldown passes + vm.warp(ts); vm.prank(RECENTER_CALLER); try lm.recenter() { // success — sell-direction recenter works @@ -239,6 +253,7 @@ contract VWAPFloorProtectionTest is UniSwapHelper { // 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); + vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes // Step 3: Second recenter — bootstrap path records VWAP. vm.prank(RECENTER_CALLER); diff --git a/onchain/test/helpers/TestBase.sol b/onchain/test/helpers/TestBase.sol index 52d996b..d78ec87 100644 --- a/onchain/test/helpers/TestBase.sol +++ b/onchain/test/helpers/TestBase.sol @@ -91,7 +91,6 @@ contract TestEnvironment is TestConstants { /** * @notice Deploy all contracts and set up the environment * @param token0shouldBeWeth Whether WETH should be token0 - * @param recenterCaller Address that will be granted recenter access * @return _factory The deployed Uniswap factory * @return _pool The created Uniswap pool * @return _weth The WETH token contract @@ -101,10 +100,7 @@ contract TestEnvironment is TestConstants { * @return _optimizer The optimizer contract * @return _token0isWeth Whether token0 is WETH */ - function setupEnvironment( - bool token0shouldBeWeth, - address recenterCaller - ) + function setupEnvironment(bool token0shouldBeWeth) external returns ( IUniswapV3Factory _factory, @@ -132,10 +128,6 @@ contract TestEnvironment is TestConstants { // Configure permissions _configurePermissions(); - // Grant recenter access to specified caller - vm.prank(feeDestination); - lm.setRecenterAccess(recenterCaller); - return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth); } @@ -172,11 +164,14 @@ contract TestEnvironment is TestConstants { /** * @notice Create and initialize the Uniswap pool + * @dev Warp 301 seconds after pool init so _isPriceStable()'s 300-second TWAP window + * has sufficient history for any subsequent recenter() call. */ function _createAndInitializePool() internal { pool = IUniswapV3Pool(factory.createPool(address(weth), address(harberg), FEE)); token0isWeth = address(weth) < address(harberg); pool.initializePoolFor1Cent(token0isWeth); + vm.warp(block.timestamp + 301); } /** @@ -202,7 +197,6 @@ contract TestEnvironment is TestConstants { /** * @notice Setup environment with specific optimizer * @param token0shouldBeWeth Whether WETH should be token0 - * @param recenterCaller Address that will be granted recenter access * @param optimizerAddress Address of the optimizer to use * @return _factory The deployed Uniswap factory * @return _pool The created Uniswap pool @@ -213,11 +207,7 @@ contract TestEnvironment is TestConstants { * @return _optimizer The optimizer contract * @return _token0isWeth Whether token0 is WETH */ - function setupEnvironmentWithOptimizer( - bool token0shouldBeWeth, - address recenterCaller, - address optimizerAddress - ) + function setupEnvironmentWithOptimizer(bool token0shouldBeWeth, address optimizerAddress) external returns ( IUniswapV3Factory _factory, @@ -248,10 +238,6 @@ contract TestEnvironment is TestConstants { // Configure permissions _configurePermissions(); - // Grant recenter access to specified caller - vm.prank(feeDestination); - lm.setRecenterAccess(recenterCaller); - return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth); } @@ -262,12 +248,8 @@ contract TestEnvironment is TestConstants { * _getOutstandingSupply skips the feeDestination KRK subtraction (already excluded * by outstandingSupply()). * @param token0shouldBeWeth Whether WETH should be token0 - * @param recenterCaller Address that will be granted recenter access */ - function setupEnvironmentWithSelfFeeDestination( - bool token0shouldBeWeth, - address recenterCaller - ) + function setupEnvironmentWithSelfFeeDestination(bool token0shouldBeWeth) external returns ( IUniswapV3Factory _factory, @@ -299,10 +281,6 @@ contract TestEnvironment is TestConstants { harberg.setLiquidityManager(address(lm)); vm.deal(address(lm), INITIAL_LM_ETH_BALANCE); - // feeDestination IS address(lm), so prank as lm to grant recenter access - vm.prank(address(lm)); - lm.setRecenterAccess(recenterCaller); - return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth); } @@ -310,7 +288,6 @@ contract TestEnvironment is TestConstants { * @notice Setup environment with existing factory and specific optimizer * @param existingFactory The existing Uniswap factory to use * @param token0shouldBeWeth Whether WETH should be token0 - * @param recenterCaller Address that will be granted recenter access * @param optimizerAddress Address of the optimizer to use * @return _factory The existing Uniswap factory * @return _pool The created Uniswap pool @@ -324,7 +301,6 @@ contract TestEnvironment is TestConstants { function setupEnvironmentWithExistingFactory( IUniswapV3Factory existingFactory, bool token0shouldBeWeth, - address recenterCaller, address optimizerAddress ) external @@ -357,10 +333,6 @@ contract TestEnvironment is TestConstants { // Configure permissions _configurePermissions(); - // Grant recenter access to specified caller - vm.prank(feeDestination); - lm.setRecenterAccess(recenterCaller); - return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth); } } diff --git a/scripts/bootstrap-common.sh b/scripts/bootstrap-common.sh index a67df08..2ee8bf8 100755 --- a/scripts/bootstrap-common.sh +++ b/scripts/bootstrap-common.sh @@ -100,50 +100,72 @@ fund_liquidity_manager() { "$LIQUIDITY_MANAGER" --value 10ether >>"$LOG_FILE" 2>&1 } -grant_recenter_access() { - bootstrap_log "Granting recenter access to deployer" - cast rpc --rpc-url "$ANVIL_RPC" anvil_impersonateAccount "$FEE_DEST" >>"$LOG_FILE" 2>&1 - cast send --rpc-url "$ANVIL_RPC" --from "$FEE_DEST" --unlocked \ - "$LIQUIDITY_MANAGER" "setRecenterAccess(address)" "$DEPLOYER_ADDR" >>"$LOG_FILE" 2>&1 - cast rpc --rpc-url "$ANVIL_RPC" anvil_stopImpersonatingAccount "$FEE_DEST" >>"$LOG_FILE" 2>&1 - if [[ -n "${TXNBOT_ADDRESS:-}" ]]; then - bootstrap_log "Granting recenter access to txnBot ($TXNBOT_ADDRESS)" - cast rpc --rpc-url "$ANVIL_RPC" anvil_impersonateAccount "$FEE_DEST" >>"$LOG_FILE" 2>&1 - cast send --rpc-url "$ANVIL_RPC" --from "$FEE_DEST" --unlocked \ - "$LIQUIDITY_MANAGER" "setRecenterAccess(address)" "$TXNBOT_ADDRESS" >>"$LOG_FILE" 2>&1 - cast rpc --rpc-url "$ANVIL_RPC" anvil_stopImpersonatingAccount "$FEE_DEST" >>"$LOG_FILE" 2>&1 - fi -} - -call_recenter() { - local recenter_pk="$DEPLOYER_PK" - local recenter_addr="$DEPLOYER_ADDR" - if [[ -n "${TXNBOT_ADDRESS:-}" ]]; then - 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. +bootstrap_vwap() { + # Idempotency guard: if a previous run already bootstrapped VWAP, skip. 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" + bootstrap_log "VWAP already bootstrapped (cumulativeVolume=$cumvol) -- skipping" return 0 fi - bootstrap_log "Calling recenter() via $recenter_addr" + local recenter_pk="${TXNBOT_PRIVATE_KEY:-$DEPLOYER_PK}" + + # Fund LM with 1 ETH (thin bootstrap positions; 0.5 ETH seed swap moves >400 ticks) + bootstrap_log "Funding LM with 1 ETH for VWAP bootstrap..." + cast send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \ + "$LIQUIDITY_MANAGER" --value 1ether >>"$LOG_FILE" 2>&1 + + # Advance Anvil time 301s so TWAP oracle has sufficient history for _isPriceStable() + cast rpc --rpc-url "$ANVIL_RPC" evm_increaseTime 301 >>"$LOG_FILE" 2>&1 + cast rpc --rpc-url "$ANVIL_RPC" evm_mine >>"$LOG_FILE" 2>&1 + + # First recenter: places initial bootstrap positions; no fees yet, cumulativeVolume stays 0 + bootstrap_log "First recenter (places bootstrap positions)..." cast send --rpc-url "$ANVIL_RPC" --private-key "$recenter_pk" \ "$LIQUIDITY_MANAGER" "recenter()" >>"$LOG_FILE" 2>&1 + + # Seed buy: wrap 0.5 ETH to WETH and swap WETH->KRK + # Generates a non-zero WETH fee in the anchor position and moves price >400 ticks. + # sqrtPriceLimitX96 is direction-dependent: MIN+1 when WETHKRK)..." + cast send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \ + "$WETH" "deposit()" --value 0.5ether >>"$LOG_FILE" 2>&1 + cast send --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \ + "$WETH" "approve(address,uint256)" "$SWAP_ROUTER" "$MAX_UINT" >>"$LOG_FILE" 2>&1 + + local weth_addr kraiken_addr sqrt_limit + weth_addr=$(echo "$WETH" | tr '[:upper:]' '[:lower:]' | sed 's/^0x//') + kraiken_addr=$(echo "$KRAIKEN" | tr '[:upper:]' '[:lower:]' | sed 's/^0x//') + if [[ "$weth_addr" < "$kraiken_addr" ]]; then + sqrt_limit=4295128740 # WETH=token0, zeroForOne=true, price decreases + else + sqrt_limit=1461446703485210103287273052203988822378723970341 # WETH=token1, price increases + fi + + cast send --legacy --gas-limit 300000 --rpc-url "$ANVIL_RPC" --private-key "$DEPLOYER_PK" \ + "$SWAP_ROUTER" "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "($WETH,$KRAIKEN,10000,$DEPLOYER_ADDR,500000000000000000,0,$sqrt_limit)" >>"$LOG_FILE" 2>&1 + + # Advance time 301s so TWAP settles at post-buy price and cooldown (60s) elapses + cast rpc --rpc-url "$ANVIL_RPC" evm_increaseTime 301 >>"$LOG_FILE" 2>&1 + cast rpc --rpc-url "$ANVIL_RPC" evm_mine >>"$LOG_FILE" 2>&1 + + # Second recenter: cumulativeVolume==0 path fires (bootstrap), ethFee>0 -> records VWAP + bootstrap_log "Second recenter (records VWAP)..." + cast send --rpc-url "$ANVIL_RPC" --private-key "$recenter_pk" \ + "$LIQUIDITY_MANAGER" "recenter()" >>"$LOG_FILE" 2>&1 + + # Verify VWAP bootstrap succeeded + cumvol="$(cast call --rpc-url "$ANVIL_RPC" \ + "$LIQUIDITY_MANAGER" "cumulativeVolume()(uint256)" 2>/dev/null || echo "0")" + if [[ "$cumvol" == "0" || -z "$cumvol" ]]; then + bootstrap_log "ERROR: VWAP bootstrap failed -- cumulativeVolume is 0" + return 1 + fi + bootstrap_log "VWAP bootstrapped (cumulativeVolume=$cumvol)" } seed_application_state() { @@ -174,8 +196,8 @@ seed_application_state() { fi bootstrap_log "Swap returned 0 KRK — recentering and retrying" - # Mine a few blocks to advance time, then recenter - cast rpc --rpc-url "$ANVIL_RPC" evm_mine >>"$LOG_FILE" 2>&1 || true + # Advance 61 s to clear the 60-second recenter cooldown, then mine a block. + cast rpc --rpc-url "$ANVIL_RPC" evm_increaseTime 61 >>"$LOG_FILE" 2>&1 || true cast rpc --rpc-url "$ANVIL_RPC" evm_mine >>"$LOG_FILE" 2>&1 || true local recenter_pk="${TXNBOT_PRIVATE_KEY:-$DEPLOYER_PK}" cast send --rpc-url "$ANVIL_RPC" --private-key "$recenter_pk" \ diff --git a/scripts/ci-bootstrap.sh b/scripts/ci-bootstrap.sh index 1ffeac6..c32e3db 100755 --- a/scripts/ci-bootstrap.sh +++ b/scripts/ci-bootstrap.sh @@ -54,15 +54,12 @@ write_deployments_json "$ONCHAIN_DIR/deployments-local.json" echo "=== deployments-local.json written ===" cat "$ONCHAIN_DIR/deployments-local.json" +echo "=== Bootstrapping VWAP ===" +bootstrap_vwap + echo "=== Funding LiquidityManager ===" fund_liquidity_manager -echo "=== Granting recenter access ===" -grant_recenter_access - -echo "=== Calling recenter() to seed liquidity ===" -call_recenter - echo "=== Seeding application state (initial swap) ===" seed_application_state diff --git a/scripts/harb-evaluator/red-team.sh b/scripts/harb-evaluator/red-team.sh index 9d8e784..2c0eeff 100755 --- a/scripts/harb-evaluator/red-team.sh +++ b/scripts/harb-evaluator/red-team.sh @@ -33,7 +33,7 @@ DEPLOYMENTS="$REPO_ROOT/onchain/deployments-local.json" # ── Anvil accounts ───────────────────────────────────────────────────────────── # Account 8 — adversary (10k ETH, 0 KRK) ADV_PK=0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97 -# Account 2 — recenter caller (granted recenterAccess by bootstrap) +# Account 2 — recenter caller (recenter() is permissionless; any account can call it) RECENTER_PK=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a # ── Infrastructure constants ─────────────────────────────────────────────────── @@ -119,21 +119,8 @@ POOL=$("$CAST" call "$V3_FACTORY" "getPool(address,address,uint24)(address)" \ "$WETH" "$KRK" "$POOL_FEE" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g') log " Pool: $POOL" -# ── 3a. Grant recenterAccess FIRST (while original feeDestination is still set) ── -FEE_DEST=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL") \ - || die "Failed to read feeDestination() from LM" -FEE_DEST=$(echo "$FEE_DEST" | sed 's/\[.*//;s/[[:space:]]//g') -log "Granting recenterAccess to account 2 ($RECENTER_ADDR) via feeDestination ($FEE_DEST) ..." -"$CAST" rpc --rpc-url "$RPC_URL" anvil_impersonateAccount "$FEE_DEST" \ - || die "anvil_impersonateAccount $FEE_DEST failed" -"$CAST" send --rpc-url "$RPC_URL" --from "$FEE_DEST" --unlocked \ - "$LM" "setRecenterAccess(address)" "$RECENTER_ADDR" >/dev/null 2>&1 \ - || die "setRecenterAccess($RECENTER_ADDR) failed" -"$CAST" rpc --rpc-url "$RPC_URL" anvil_stopImpersonatingAccount "$FEE_DEST" \ - || die "anvil_stopImpersonatingAccount $FEE_DEST failed" -log " recenterAccess granted" - -# ── 3b. Set feeDestination to LM itself (fees accrue as liquidity) ───────────── +# ── 3a. Set feeDestination to LM itself (fees accrue as liquidity) ───────────── +# recenter() is now permissionless — no setRecenterAccess() call needed. # setFeeDestination allows repeated EOA sets; setting to a contract locks it permanently. # The deployer (Anvil account 0) deployed LiquidityManager and may call setFeeDestination again. # DEPLOYER_PK is Anvil's deterministic account-0 key — valid ONLY against a local ephemeral @@ -147,7 +134,7 @@ VERIFY=$("$CAST" call "$LM" "feeDestination()(address)" --rpc-url "$RPC_URL" | s log " feeDestination set to: $VERIFY" [[ "${VERIFY,,}" == "${LM,,}" ]] || die "feeDestination verification failed: expected $LM, got $VERIFY" -# ── 3c. Fund LM with 1000 ETH and deploy into positions via recenter ─────────── +# ── 3b. Fund LM with 1000 ETH and deploy into positions via recenter ─────────── # Send ETH as WETH (LM uses WETH internally), then recenter to deploy into positions. # Without recenter, the ETH sits idle and the first recenter mints massive KRK. log "Funding LM with 1000 ETH ..." @@ -177,7 +164,7 @@ LM_ETH=$("$CAST" balance "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:] LM_WETH=$("$CAST" call "$WETH" "balanceOf(address)(uint256)" "$LM" --rpc-url "$RPC_URL" | sed 's/\[.*//;s/[[:space:]]//g') log " LM after recenter: ETH=$LM_ETH WETH=$LM_WETH" -# ── 4. Take Anvil snapshot (clean baseline, includes recenterAccess grant) ───── +# ── 4. Take Anvil snapshot (clean baseline) ──────────────────────────────────── log "Taking Anvil snapshot..." SNAP=$("$CAST" rpc anvil_snapshot --rpc-url "$RPC_URL" | tr -d '"') log " Snapshot ID: $SNAP" @@ -422,7 +409,7 @@ CAST binary: /home/debian/.foundry/bin/cast ### Recenter caller — Anvil account 2 - Address: ${RECENTER_ADDR} - Private key: ${RECENTER_PK} -- Has recenterAccess on LiquidityManager +- Can call recenter() (permissionless — 60s cooldown + TWAP check enforced) --- @@ -449,7 +436,7 @@ to rebalance, then re-deploys positions at the current price. It: - Can mint NEW KRK (increasing supply → decreasing floor) - Can burn KRK (decreasing supply → increasing floor) - Moves ETH between positions -Only recenterAccess account can call it. +recenter() is permissionless — any account can call it (subject to 60s cooldown and TWAP check). ### Staking \`Stake.snatch(assets, receiver, taxRateIndex, positionsToSnatch)\` diff --git a/tests/e2e/04-recenter-positions.spec.ts b/tests/e2e/04-recenter-positions.spec.ts index 9b504d6..aa82106 100644 --- a/tests/e2e/04-recenter-positions.spec.ts +++ b/tests/e2e/04-recenter-positions.spec.ts @@ -7,7 +7,6 @@ const STACK_RPC_URL = STACK_CONFIG.rpcUrl; // Solidity function selectors const POSITIONS_SELECTOR = '0xf86aafc0'; // positions(uint8) const RECENTER_SELECTOR = '0xf46e1346'; // recenter() -const RECENTER_ACCESS_SELECTOR = '0xdef51130'; // recenterAccess() // Position stages (matches ThreePositionStrategy.Stage enum) const STAGE_FLOOR = 0; @@ -102,50 +101,42 @@ test.describe('Recenter Positions', () => { console.log('[TEST] All three positions have non-zero liquidity'); }); - test('recenter() enforces access control', async () => { + test('recenter() is public — any address may attempt it', async () => { const lmAddress = STACK_CONFIG.contracts.LiquidityManager; - // Read the recenterAccess address - const recenterAccessResult = (await rpcCall('eth_call', [ - { to: lmAddress, data: RECENTER_ACCESS_SELECTOR }, - 'latest', - ])) as string; - const recenterAddr = '0x' + recenterAccessResult.slice(26); - console.log(`[TEST] recenterAccess: ${recenterAddr}`); - expect(recenterAddr).not.toBe('0x' + '0'.repeat(40)); - console.log('[TEST] recenterAccess is set (not zero address)'); - - // Try calling recenter from an unauthorized address — should revert with "access denied" - const unauthorizedAddr = '0x1111111111111111111111111111111111111111'; + // recenter() is now public: anyone can call it (recenterAccess was removed). + // After bootstrap the cooldown and amplitude guards will typically fire, + // but the revert reason must NOT be "access denied". + const callerAddr = '0x1111111111111111111111111111111111111111'; const callResult = await rpcCallRaw('eth_call', [ - { from: unauthorizedAddr, to: lmAddress, data: RECENTER_SELECTOR }, + { from: callerAddr, to: lmAddress, data: RECENTER_SELECTOR }, 'latest', ]); - expect(callResult.error).toBeDefined(); - expect(callResult.error!.message).toContain('access denied'); - console.log('[TEST] Unauthorized recenter correctly rejected with "access denied"'); + if (callResult.error) { + // Acceptable guard errors: cooldown, amplitude, TWAP — NOT access control + const msg = callResult.error.message ?? ''; + expect(msg).not.toContain('access denied'); + console.log(`[TEST] Recenter guard active (expected): ${msg}`); + console.log('[TEST] No "access denied" — access control correctly removed'); + } else { + console.log('[TEST] Recenter succeeded from arbitrary address — access control is gone'); + } }); test('recenter() enforces amplitude check', async () => { const lmAddress = STACK_CONFIG.contracts.LiquidityManager; - // Read the recenterAccess address - const recenterAccessResult = (await rpcCall('eth_call', [ - { to: lmAddress, data: RECENTER_ACCESS_SELECTOR }, - 'latest', - ])) as string; - const recenterAddr = '0x' + recenterAccessResult.slice(26); - - // Call recenter from the authorized address without moving the price - // Should revert with "amplitude not reached" since price hasn't moved enough + // Call recenter from any address without moving the price. + // Should revert with a guard error (cooldown, amplitude, or TWAP), not crash. + const callerAddr = '0x1111111111111111111111111111111111111111'; const callResult = await rpcCallRaw('eth_call', [ - { from: recenterAddr, to: lmAddress, data: RECENTER_SELECTOR }, + { from: callerAddr, to: lmAddress, data: RECENTER_SELECTOR }, 'latest', ]); // After bootstrap's initial swap + recenter, calling recenter again may either: - // - Fail with "amplitude not reached" if price hasn't moved enough + // - Fail with "amplitude not reached" / "recenter cooldown" / "price deviated from oracle" // - Succeed if contract's amplitude threshold allows it (e.g., after swap moved price) // Both outcomes are valid — the key invariant is that recenter doesn't crash unexpectedly if (callResult.error) {