Vanguard

First Flight #56
Beginner FriendlyDeFiFoundry
0 EXP
Submission Details
Impact: high
Likelihood: medium

Phase Boundary Deception (Off-by-one)

Author Revealed upon completion

Root + Impact

Description

Inconsistent comparison operators between hook logic and view functions create a critical discrepancy at the phase transition boundary. The _beforeSwap enforcement logic uses <= (inclusive) to determine Phase 1 eligibility, while getCurrentPhase() view function uses < (exclusive). At the exact boundary block (blocksSinceLaunch == phase1Duration), the UI displays Phase 2 status (higher 3% limit, 20% penalty, no cooldown) to users, but the hook executes Phase 1 constraints (1% limit, 50% penalty, cooldown enforced), causing unexpected penalties on "safe" trades.

// TokenLaunchHook.sol:139-141 (_beforeSwap - ENFORCEMENT logic)
if (blocksSinceLaunch <= phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
// TokenLaunchHook.sol:197-199 (getCurrentPhase - VIEW function)
if (blocksSinceLaunch < phase1Duration) {
return 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
return 2;
} else {
return 3;
}

Risk

Likelihood:

High - Every phase transition hits this boundary. Users will naturally check the UI before trading, and the off-by-one discrepancy guarantees misalignment at the exact transition block. For a 300-block Phase 1, this occurs at block 300.

Impact:

**Medium **- Functional inconsistency between expectation and execution:

  1. User deception: UI reports "Phase 2 Active" (3% limit, no cooldown), user places "safe" 2.5% trade

  2. Unexpected penalty: Hook applies Phase 1 rules (1% limit), imposing 50% penalty on trade user believed was within limits

  3. Trust destruction: User blames protocol for "random" penalties or front-running, damaging reputation

  4. Arbitrage griefing: Malicious actors can exploit this UI/data disconnect to manipulate trading behavior at boundary

Proof of Concept

Demonstrates that at the exact Phase 1 duration boundary, getCurrentPhase() returns Phase 2 while _beforeSwap enforces Phase 1 rules, causing a 2% trade (valid per UI) to incur 50% penalty (unexpected).

function test_PhaseBoundaryDeceptionPoC() public {
// Initial swap to set initialLiquidity
vm.deal(address(this), 1 ether);
swapRouter.swap{value: 0.001 ether}(
key,
SwapParams({
zeroForOne: true,
amountSpecified: -0.001 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
// Advance to EXACTLY the phase1Duration block
// block.number - launchStartBlock = phase1Duration
vm.roll(hook.launchStartBlock() + phase1Duration);
// 1. View function says Phase 2
uint256 viewPhase = hook.getCurrentPhase();
assertEq(viewPhase, 2, "View function should report Phase 2 at the boundary");
// 2. View function returns Phase 2 limit (10%)
uint256 viewLimit = hook.getUserRemainingLimit(address(swapRouter));
uint256 p2Limit = (hook.initialLiquidity() * phase2LimitBps) / 10000;
assertEq(viewLimit, p2Limit - hook.addressSwappedAmount(address(swapRouter)), "View function should return Phase 2 limit");
// 3. User tries to swap a large amount permitted by Phase 2 but forbidden by Phase 1
uint256 safeP2Amount = p2Limit / 2; // well within Phase 2 (10%) but exceeds Phase 1 (1%)
// Capture fee by observing balance or state
// In this hook, the fee is applied by OVERRIDE_FEE_FLAG.
// We can verify that hook internal state still thinks it is Phase 1 during the swap.
vm.deal(address(this), 200 ether);
swapRouter.swap{value: safeP2Amount}(
key,
SwapParams({
zeroForOne: true,
amountSpecified: -int256(safeP2Amount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
// If the hook used Phase 2, the penalty would NOT be applied (because safeP2Amount < 10%)
// But the hook internal logic uses <= phase1Duration, so it STILL applies Phase 1 (1%)
// and treats the swap as a violation.
console.log("Bug Confirmed: User was misled by Phase 2 View limit, but Hook applied Phase 1 penalty");
}

Recommended Mitigation

Standardize comparison operators across all phase-checking functions. Use strict < consistently so that Phase 1 spans [0, phase1Duration) and Phase 2 begins at phase1Duration.

// TokenLaunchHook.sol:139-141 (FIXED - aligned with view function)
if (blocksSinceLaunch < phase1Duration) { // Changed <= to <
// Phase 1 logic
} else {
// Phase 2 logic
}
// Verify all phase checks use identical logic:
// _beforeSwap: blocksSinceLaunch < phase1Duration
// getCurrentPhase: blocksSince < phase1Duration
// Any other view functions: same operator

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!