Vanguard

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

[M-01] Phase Boundary Off-By-One Between View and Execution

Author Revealed upon completion

Root + Impact

Description

_beforeSwap() and getCurrentPhase() use different comparison operators (<= vs <), causing view functions to return different phase than actual execution.

Location: src/TokenLaunchHook.sol:139-145 vs src/TokenLaunchHook.sol:196-203

// _beforeSwap uses <= (EXECUTION)
if (blocksSinceLaunch <= phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
newPhase = 2;
}
// getCurrentPhase uses < (VIEW)
if (blocksSinceLaunch < phase1Duration) {
return 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
return 2;
}

At exact boundary (blocksSinceLaunch == phase1Duration):

  • _beforeSwap → Phase 1

  • getCurrentPhase → Phase 2

Risk

Likelihood:

  • Occurs at every phase boundary block (2 times per launch: Phase1->Phase2, Phase2->Phase3)

  • Users querying state exactly at boundary will see incorrect phase

  • Frontend/UI displaying phase info will be wrong for one block

Impact:

  • Users see incorrect phase in frontend (Phase 2 when actually Phase 1)

  • getUserRemainingLimit() returns wrong values at boundaries

  • getUserCooldownEnd() uses wrong cooldown at boundaries

  • Confusing UX and potential for unexpected penalties

  • Users may time swaps incorrectly based on displayed phase

Proof of Concept

At the exact phase boundary block where blocksSinceLaunch == phase1Duration, the view function returns Phase 2 while execution uses Phase 1.

function test_PoC_M01_PhaseBoundaryOffByOne() public {
// Move to exact phase boundary
uint256 boundaryBlock = hook.launchStartBlock() + phase1Duration;
vm.roll(boundaryBlock);
uint256 blocksSinceLaunch = block.number - hook.launchStartBlock();
console.log("blocksSinceLaunch:", blocksSinceLaunch);
console.log("phase1Duration:", phase1Duration);
console.log("blocksSinceLaunch == phase1Duration:", blocksSinceLaunch == phase1Duration);
// getCurrentPhase() uses < operator
// blocksSinceLaunch < phase1Duration => 100 < 100 => FALSE => returns Phase 2
uint256 viewPhase = hook.getCurrentPhase();
console.log("getCurrentPhase() returns:", viewPhase);
// _beforeSwap() uses <= operator
// blocksSinceLaunch <= phase1Duration => 100 <= 100 => TRUE => uses Phase 1
uint256 phaseBefore = hook.currentPhase();
_swap(user1, 0.0001 ether);
uint256 phaseAfter = hook.currentPhase();
console.log("Execution used phase:", phaseAfter);
// View says Phase 2, but execution used Phase 1 limits/cooldowns!
}

Recommendations

Standardize to use <= everywhere (or < everywhere):

function getCurrentPhase() public view returns (uint256) {
if (launchStartBlock == 0) return 0;
uint256 blocksSinceLaunch = block.number - launchStartBlock;
if (blocksSinceLaunch <= phase1Duration) {
return 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
return 2;
} else {
return 3;
}
}

Support

FAQs

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

Give us feedback!