- Update M-3 finding: recenterAccess was removed; MIN_RECENTER_INTERVAL (60s) cooldown now enforced unconditionally — downgrade severity to Informational (resolved) - Update Access Control Summary: remove recenterAccess rows, reflect permissionless recenter() with cooldown - Update Conclusion: mark M-3 as resolved - Fix stale M-1 impact note that mentioned recenterAccess as a workaround - deployment.md: remove Section 3.2 "Set Recenter Access" (setRecenterAccess no longer exists); update 3.3 first-recenter comment - deployment.md: replace recenterAccess() verification call with lastRecenterTime() check - deployment.md §6.1: rewrite Pause Recentering note — no access-control switch exists, cooldown is the only rate limiter - deployment.md §6.5: remove stale setRecenterAccess(0xdEaD) instruction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
10 KiB
Security Review: KRAIKEN Protocol Contracts
Date: 2026-02-13
Branch: launch/protocol-readiness
Reviewer: Automated deep review (Claude)
Scope: LiquidityManager, OptimizerV3, ThreePositionStrategy, VWAPTracker, PriceOracle, Kraiken, Stake
Executive Summary
The KRAIKEN protocol contracts are well-structured with clear separation of concerns. No critical exploitable vulnerabilities were found that would block mainnet launch. Two medium-severity issues should be addressed before deployment: a TWAP calculation bug in the PriceOracle fallback path, and the lack of access control on one-time setter functions during deployment. Several low-severity and informational findings are documented below.
The previously identified Floor Ratchet Extraction Attack (branch fix/floor-ratchet) remains the highest-priority issue and is tracked separately.
Findings
M-1: PriceOracle Fallback TWAP Uses Wrong Divisor
Severity: Medium
File: src/abstracts/PriceOracle.sol:37-40
// Fallback to longer timeframe if recent data unavailable
secondsAgo[0] = PRICE_STABILITY_INTERVAL * 200; // 60,000 seconds
(int56[] memory tickCumulatives,) = pool.observe(secondsAgo);
int56 tickCumulativeDiff = tickCumulatives[1] - tickCumulatives[0];
averageTick = int24(tickCumulativeDiff / int56(int32(PRICE_STABILITY_INTERVAL))); // divides by 300, not 60000
Issue: The fallback path observes a 60,000-second window but divides by 300 (the original 5-minute interval). This produces an averageTick that is 200x the actual TWAP value.
Impact: When the fallback triggers (new pool with <5min of observation history), _isPriceStable() always returns false, making recenter() permanently blocked until enough observations accumulate. This is a liveness issue for newly deployed pools — the protocol cannot perform its first recenter until the pool has accumulated sufficient TWAP history.
Note: This is fail-safe (blocks recenter rather than allowing manipulation), so it's not exploitable. However, it could delay mainnet activation.
Fix:
uint32 fallbackInterval = PRICE_STABILITY_INTERVAL * 200;
secondsAgo[0] = fallbackInterval;
(int56[] memory tickCumulatives,) = pool.observe(secondsAgo);
int56 tickCumulativeDiff = tickCumulatives[1] - tickCumulatives[0];
averageTick = int24(tickCumulativeDiff / int56(int32(fallbackInterval)));
M-2: One-Time Setters Lack Access Control (Deployment Race)
Severity: Medium Files:
src/LiquidityManager.sol:102-106—setFeeDestination()src/Kraiken.sol:64-68—setLiquidityManager()src/Kraiken.sol:76-80—setStakingPool()
Issue: These set-once functions have no msg.sender restriction. Anyone can call them before the deployer:
function setFeeDestination(address feeDestination_) external {
if (address(0) == feeDestination_) revert ZeroAddressInSetter();
if (feeDestination != address(0)) revert AddressAlreadySet();
feeDestination = feeDestination_; // first caller wins
}
Impact: An attacker watching the mempool could frontrun deployment to:
- Set themselves as
feeDestination→ steal all LP fees forever - Set a malicious
liquidityManager→ gain mint/burn control over KRK supply
Mitigating factors:
DeployBase.solcalls all setters in the same broadcast transaction as deployment- On Base L2, sequencer ordering reduces frontrunning risk vs L1
- Using private mempools / bundled transactions eliminates the risk entirely
Recommendation: Either:
- Accept the risk with bundled deployment (current approach works on Base), or
- Add a constructor-set
deployeraddress as the only authorized caller for these setters
M-3: Open recenter() Access Without Rate Limiting (Addressed)
Severity: Medium (griefing) → Informational (resolved)
File: src/LiquidityManager.sol:155-172
Original concern: When recenterAccess == address(0), anyone could call recenter() as long as the TWAP check passed, with no cooldown or rate limiting.
Resolution: The recenterAccess role and its associated setter/revoker functions (setRecenterAccess, revokeRecenterAccess) have been removed from the contract. recenter() is now unconditionally permissionless. In their place, a MIN_RECENTER_INTERVAL = 60 second cooldown is enforced on every call path with no bypass:
function recenter() external returns (bool isUp) {
(, int24 currentTick,,,,,) = pool.slot0();
// 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;
Assessment: The 60-second cooldown directly addresses the original gas-griefing and VWAP-frequency concerns. Combined with the amplitude check (>400 ticks from center) and TWAP oracle guard (5-min, 50-tick tolerance), the attack surface is materially reduced. No further action required.
L-1: Division by Zero Edge Case in Kraiken mint/burn
Severity: Low
File: src/Kraiken.sol:109, 130
uint256 newStake = stakingPoolBalance * _amount / (totalSupply() - stakingPoolBalance);
Issue: If totalSupply() == stakingPoolBalance (staking pool holds 100% of tokens), the denominator is zero.
Impact: recenter() reverts when trying to mint KRK for new positions.
Mitigating factors: In practice, the LM always holds significant KRK in positions, making totalSupply() > stakingPoolBalance invariant. The only way to reach this state would be for the LM to burn all its tokens AND for all remaining supply to be in the staking pool — which would require zero active positions (impossible mid-operation since recenter burns then mints).
L-2: OptimizerV3 Integer Truncation at Bull/Bear Boundary
Severity: Low
File: src/OptimizerV3.sol:152
uint256 stakedPct = percentageStaked * 100 / 1e18; // truncates, doesn't round
Issue: 91.9% staked truncates to 91, triggering bear mode even though staking is close to the 92% threshold.
Impact: The bull/bear boundary has ~1% hysteresis due to truncation. This is actually beneficial — it makes the boundary slightly harder to reach, adding a buffer against oscillation.
I-1: Missing Recentered Event
Severity: Informational
File: src/LiquidityManager.sol:121
recenter() performs the most critical protocol operation but emits no event. The EthScarcity/EthAbundance events exist in ThreePositionStrategy but only fire during floor tick computation. A top-level Recentered(int24 tick, bool isUp) event would improve monitoring and indexing.
I-2: VWAP Directional Recording Is Sound But Has Known Limitations
Severity: Informational
File: src/LiquidityManager.sol:146-158
The directional VWAP recording (only record on ETH inflow / buys) is a deliberate design choice to prevent sell-side VWAP dilution. An attacker could theoretically buy to inflate VWAP, then sell without VWAP recording. However:
- Buying costs real ETH (not free to manipulate)
- VWAP is volume-weighted, so one-off manipulation is diluted by historical volume
- The VWAP mirror defense naturally increases floor distance during sell pressure
This is acceptable behavior by design.
I-3: ThreePositionStrategy Floor Position at Zero Outstanding Supply
Severity: Informational
File: src/abstracts/ThreePositionStrategy.sol:191-192
When outstandingSupply reaches 0 after subtracting pulledKraiken and discoveryAmount, the scarcity tick goes to MAX_TICK (extreme KRK-cheap). This is correct by design — when there's no outstanding supply to protect, the floor should be as far as possible from current price, locking ETH in a position that's virtually unreachable.
Access Control Summary
| Function | Contract | Access | Notes |
|---|---|---|---|
recenter() |
LiquidityManager | Anyone (TWAP-gated + 60s cooldown) | recenterAccess role removed; cooldown enforced unconditionally |
setFeeDestination() |
LiquidityManager | Deployer only | Restricted post-refactor |
mint() / burn() |
Kraiken | onlyLiquidityManager |
Secure |
setLiquidityManager() |
Kraiken | Anyone (set-once) | Race condition risk |
setStakingPool() |
Kraiken | Anyone (set-once) | Race condition risk |
upgradeTo() |
OptimizerV3 (proxy) | onlyAdmin (deployer) |
Secure |
initialize() |
OptimizerV3 | initializer guard (once) |
Secure |
uniswapV3MintCallback() |
LiquidityManager | CallbackValidation.verifyCallback |
Secure |
Reentrancy Analysis
recenter(): No reentrancy risk. The function:
- Reads pool state (slot0)
- Burns all positions via
pool.burn()(Uniswap V3 pools are not reentrant) - Collects tokens via
pool.collect() - Transfers fees to
feeDestination - Mints new positions via
pool.mint()
The uniswapV3MintCallback is validated via CallbackValidation.verifyCallback(factory, poolKey) which ensures only the canonical pool can trigger it. The callback mints KRK tokens and wraps ETH — neither of which creates reentrant paths back to recenter().
_scrapePositions(): Token transfers (IERC20.transfer) to feeDestination could theoretically trigger a callback if feeDestination is a contract. However, WETH and KRK transfers do not have callback hooks (no ERC-777 or similar), so this is safe.
Known Issues (Tracked Separately)
Floor Ratchet Extraction Attack
Branch: fix/floor-ratchet
Severity: High (exploitable in 2000+ trade scenarios)
Summary: Rapid recenters ratchet the floor position toward current price while packing ETH into it, enabling extraction through coordinated buy-crash-recenter-sell cycles. See MEMORY.md deep fuzzing results for full analysis.
Conclusion
The protocol is ready for mainnet deployment with the following pre-launch actions:
- Fix M-1 (PriceOracle fallback divisor) — simple one-line fix
- Mitigate M-2 by using bundled transactions for deployment (already the case in DeployBase.sol)
Mitigate M-3— Resolved:recenterAccesswas removed;MIN_RECENTER_INTERVAL(60s) cooldown is now enforced unconditionally on allrecenter()calls- Continue tracking the Floor Ratchet vulnerability on its dedicated branch