Merge pull request 'fix: Remove recenterAccess — make recenter() public with TWAP enforcement (#706)' (#713) from fix/issue-706 into master

This commit is contained in:
johba 2026-03-14 10:48:59 +01:00
commit 6ff8282a7e
19 changed files with 241 additions and 330 deletions

View file

@ -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

View file

@ -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=<deployed LiquidityManager 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();
}
}

View file

@ -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 <RPC> --broadcast");
console.log(" This performs the second recenter that records cumulativeVolume > 0.");
console.log(" 3. Fund LiquidityManager with operational ETH.");
console.log(" 4. recenter() is permissionless - any address (e.g. txnBot) can call it.");
vm.stopBroadcast();
}

View file

@ -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)\" <txnBotAddr>");
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();
}

View file

@ -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 =

View file

@ -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.

View file

@ -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

View file

@ -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));

View file

@ -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;

View file

@ -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);

View file

@ -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)
);

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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 WETH<KRK (token0), MAX-1 otherwise.
bootstrap_log "Executing seed buy (0.5 ETH WETH->KRK)..."
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" \

View file

@ -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

View file

@ -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)\`

View file

@ -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) {