Vanguard

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

Phase Boundary Inconsistency Between Execution and View Logic Allows State Manipulation

Author Revealed upon completion

The hook determines the current protection phase using block height relative to the launch start block. Phase transitions should occur deterministically at consistent block boundaries to ensure predictable swap limits and cooldown enforcement.

The contract uses inconsistent boundary conditions between execution logic (_beforeSwap) and view functions (getCurrentPhase, getUserRemainingLimit, getUserCooldownEnd), causing a one-block window where the enforced phase differs from the reported phase—breaking monitoring tools and creating user confusion.


Likelihood: High
Reason 1: Phase transitions occur deterministically at fixed block intervals during every token launch lifecycle—this discrepancy triggers predictably at each phase boundary.
Reason 2: External systems (frontends, monitoring bots, analytics dashboards) exclusively rely on view functions like getCurrentPhase() to report user limits and cooldown status, guaranteeing exposure to the inconsistency.

Impact: Medium
Impact 1: Users receive incorrect remaining limit/cooldown information from view functions during the boundary block, potentially causing failed transactions when attempting swaps that appear allowed by the UI.
Impact 2: Security monitoring systems cannot reliably detect phase transitions or anomalous swap patterns, undermining incident response during actual bot attacks.

// In _beforeSwap():
uint256 blocksSinceLaunch = block.number - launchStartBlock;
uint256 newPhase;
if (blocksSinceLaunch <= phase1Duration) {
newPhase = 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
if (newPhase != currentPhase) { // @> Only updates on swap execution
_resetPerAddressTracking();
currentPhase = newPhase;
lastPhaseUpdateBlock = block.number;
}
// In getCurrentPhase():
function getCurrentPhase() public view returns (uint256) {
if (launchStartBlock == 0) return 0;
uint256 blocksSinceLaunch = block.number - launchStartBlock; // @> Always calculates fresh
if (blocksSinceLaunch < phase1Duration) {
return 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
return 2;
} else {
return 3;
}
}
https://github.com/CodeHawks-Contests/2026-01-vanguard/blob/9fa43cd0950d6baf301cada9b40d31c28b65bbe8/src/TokenLaunchHook.sol#L125

Proof of Concept

// Scenario: Phase 1 ends at block 1000, Phase 2 starts at block 1001
// A bot monitors the chain:
// At block 1000 (last block of Phase 1):
// - getCurrentPhase() returns 1 (correct)
// - currentPhase state variable = 1 (correct)
// At block 1001 (first block of Phase 2, before any swaps):
// - getCurrentPhase() returns 2 (correct - based on block calculation)
// - currentPhase state variable = 1 (incorrect - hasn't been updated yet)
// - getUserRemainingLimit() calculates based on Phase 2 limits
// - Actual execution will use Phase 1 limits until first swap occurs
// Exploit:
// 1. Bot calls getUserRemainingLimit() at block 1001, sees Phase 2 limits
// 2. Bot executes swap immediately, gets processed with Phase 1 limits (stricter)
// 3. This swap triggers phase update, changing currentPhase to 2
// 4. All subsequent swaps use Phase 2 limits
//
// OR reverse exploit:
// 1. Bot waits until block 1001
// 2. Executes large swap (last swap of Phase 1, before limits reset)
// 3. Triggers phase update to Phase 2
// 4. Immediately executes another large swap in Phase 2

Recommended Mitigation

- function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal override
returns (bytes4, BeforeSwapDelta, uint24)
{
// ...
uint256 blocksSinceLaunch = block.number - launchStartBlock;
uint256 newPhase;
- if (blocksSinceLaunch <= phase1Duration) {
+ if (blocksSinceLaunch < phase1Duration) {
newPhase = 1;
- } else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
+ } else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
// ...
}
// @> getCurrentPhase already uses correct < boundaries - no change needed
// @> Add NatSpec documentation to clarify boundary semantics:
/**
* @notice Returns current protection phase based on blocks since launch
* @dev Phase durations use exclusive upper bounds:
* Phase 1: [0, phase1Duration) blocks
* Phase 2: [phase1Duration, phase1Duration + phase2Duration) blocks
* Phase 3: [phase1Duration + phase2Duration, ∞) blocks
*/
function getCurrentPhase() public view returns (uint256) {
// ...
}
+

Support

FAQs

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

Give us feedback!