Vanguard

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

Phase logic inconsistency

Author Revealed upon completion

Description

  • The hook enforces time‑boxed “phases” during the launch window. A view function, getCurrentPhase(), is intended to report the same phase the enforcement logic applies, so UIs/wallets can show accurate limits and fees.

  • The boundary comparison differs between the enforcement path in _beforeSwap and the read‑only getCurrentPhase().

// Enforcement path (TokenLaunchHook._beforeSwap)
uint256 blocksSinceLaunch = block.number - launchStartBlock;
uint256 newPhase;
if (blocksSinceLaunch <= phase1Duration) { // @> uses <=
newPhase = 1;
} else if (blocksSinceLaunch <= phase1Duration + phase2Duration) {
newPhase = 2;
} else {
newPhase = 3;
}
// View path (TokenLaunchHook.getCurrentPhase)
uint256 blocksSinceLaunch = block.number - launchStartBlock;
if (blocksSinceLaunch < phase1Duration) { // @> uses <
return 1;
} else if (blocksSinceLaunch < phase1Duration + phase2Duration) {
return 2;
} else {
return 3;
}

Risk

Likelihood: Low

  • This occurs at the phase boundary block launchStartBlock + phase1Duration - a moment likely to be targeted by users/bots for larger trades exactly when limits are expected to relax.

  • UIs commonly call view methods (like getCurrentPhase) to display state, so the mismatch between UI and enforcement is realistic in production.

Impact: Low

  • Unexpected penalties / failed trades: Users submitting swaps sized for Phase‑2 limits (as shown by the view) can be penalized or see unexpected behavior because the hook still applies Phase‑1 caps and cooldowns on that exact block.

  • Unexpected penalties / failed trades: Users submitting swaps sized for Phase‑2 limits (as shown by the view) can be penalized or see unexpected behavior because the hook still applies Phase‑1 caps and cooldowns on that exact block.

Proof of Concept

  • Create PhaseLogicBoundaryInconsistency.t.sol under test directory and copy code below.

  • Run command forge test --mt test_PhaseBoundary_Inconsistency -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {TokenLaunchHook} from "../src/TokenLaunchHook.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
contract PhaseLogicBoundaryInconsistencyTest is Test, Deployers, ERC1155TokenReceiver {
TokenLaunchHook hook;
MockERC20 token;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
uint256 phase1Duration = 100;
uint256 phase2Duration = 200;
uint256 phase1LimitBps = 100;
uint256 phase2LimitBps = 300;
uint256 phase1Cooldown = 5;
uint256 phase2Cooldown = 3;
uint256 phase1PenaltyBps = 500;
uint256 phase2PenaltyBps = 200;
function setUp() public {
deployFreshManagerAndRouters();
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
token.mint(address(this), 1_000 ether);
// Mine correct permission flags: AFTER_INITIALIZE + BEFORE_SWAP
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory args = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG);
(address mined, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, args);
hook = new TokenLaunchHook{salt: salt}(
IPoolManager(manager),
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(hook) == mined, "Hook address mismatch");
token.approve(address(modifyLiquidityRouter), type(uint256).max);
// Initialize dynamic-fee pool
(key,) = initPool(
ethCurrency,
tokenCurrency,
hook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
// Add some liquidity so swaps work
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 10 ether;
vm.deal(address(this), ethToAdd + 1 ether); // provide ETH for settle
uint128 liqDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1, sqrtPriceAtTickUpper, ethToAdd
);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd + 0.5 ether}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liqDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
function test_PhaseBoundary_Inconsistency() public {
// Roll to the exact boundary block: launchStartBlock + phase1Duration
uint256 start = hook.launchStartBlock();
vm.roll(start + phase1Duration);
// 1) View says Phase 2 at the boundary (strict < in getCurrentPhase)
assertEq(hook.getCurrentPhase(), 2, "view reports Phase 2 at boundary");
// 2) Enforcement state remains Phase 1 (since _beforeSwap uses <=)
assertEq(hook.currentPhase(), 1, "state/enforcement still in Phase 1 at boundary");
// 3) Trigger a swap and observe enforcement remains in Phase 1 on this exact block
uint256 swapAmount = 0.001 ether;
vm.deal(address(this), swapAmount);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(swapAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory settings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: swapAmount}(key, params, settings, ZERO_BYTES);
assertEq(hook.currentPhase(), 1, "enforcement path applied Phase 1 on the boundary block");
}
}

Output:

[⠊] Compiling...
[⠢] Compiling 2 files with Solc 0.8.26
[⠆] Solc 0.8.26 finished in 74.99ms
No files changed, compilation skipped
Ran 1 test for test/PhaseLogicBoundaryInconsistency.t.sol:PhaseLogicBoundaryInconsistencyTest
[PASS] test_PhaseBoundary_Inconsistency() (gas: 190470)
Traces:
[190470] PhaseLogicBoundaryInconsistencyTest::test_PhaseBoundary_Inconsistency()
├─ [2872] TokenLaunchHook::launchStartBlock() [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::roll(101)
│ └─ ← [Return]
├─ [945] TokenLaunchHook::getCurrentPhase() [staticcall]
│ └─ ← [Return] 2
├─ [2247] TokenLaunchHook::currentPhase() [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::deal(PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1000000000000000 [1e15])
│ └─ ← [Return]
├─ [152124] PoolSwapTest::swap{value: 1000000000000000}(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), TestSettings({ takeClaims: false, settleUsingBurn: false }), 0x)
│ ├─ [146320] PoolManager::unlock(0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015cf58144ef33af1e14b5208015d11f9143e27b90000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000007e4a8f76fec89ed2ebf193e4e5cc1c1ab32e90800000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffffffffffffffffffffffc72815b39800000000000000000000000000000000000000000000000000000000001000276a400000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ ├─ [144061] PoolSwapTest::unlockCallback(0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e149600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015cf58144ef33af1e14b5208015d11f9143e27b90000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000007e4a8f76fec89ed2ebf193e4e5cc1c1ab32e90800000000000000000000000000000000000000000000000000000000000000001fffffffffffffffffffffffffffffffffffffffffffffffffffc72815b39800000000000000000000000000000000000000000000000000000000001000276a400000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ │ ├─ [862] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [2552] MockERC20::balanceOf(PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ │ │ │ └─ ← [Return] 990000000000000000000 [9.9e20]
│ │ │ ├─ [2552] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 10000000000000000000 [1e19]
│ │ │ ├─ [862] PoolManager::exttload(0x85be7c2bd5cfd9e6e3a30072d5be012f0c0649c579d3433f4d5ee458bdb429be) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [103736] PoolManager::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ ├─ [72095] TokenLaunchHook::beforeSwap(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x15cF58144EF33af1e14b5208015d11F9143E27b9, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x7E4A8F76FEc89Ed2EbF193e4E5Cc1c1ab32E9080 }), SwapParams({ zeroForOne: true, amountSpecified: -1000000000000000 [-1e15], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ │ ├─ [2378] PoolManager::extsload(0xa88b2edf4fb72aaf361d8b4ec9d1adbe5291882f188b44e71deb27a0f6bb7388) [staticcall]
│ │ │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000b4fb077cf724ed8bd3
│ │ │ │ │ └─ ← [Return] 0x575e24b4, 0, 4194304 [4.194e6]
│ │ │ │ ├─ emit Swap(id: 0xc6eac5486de8d3ffee00cfbcd1e87bfd63f7fbfb9d06fcf16e570fe9fa93a4b5, sender: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount0: -1000000000000000 [-1e15], amount1: 999999700464594 [9.999e14], sqrtPriceX96: 79228138782624522581392935400 [7.922e28], liquidity: 3338502497096994491347 [3.338e21], tick: -1, fee: 0)
│ │ │ │ └─ ← [Return] -340282366920938463463374607431768211455000000299535406 [-3.402e53]
│ │ │ ├─ [862] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0xfffffffffffffffffffffffffffffffffffffffffffffffffffc72815b398000
│ │ │ ├─ [552] MockERC20::balanceOf(PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496]) [staticcall]
│ │ │ │ └─ ← [Return] 990000000000000000000 [9.9e20]
│ │ │ ├─ [552] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 10000000000000000000 [1e19]
│ │ │ ├─ [862] PoolManager::exttload(0x85be7c2bd5cfd9e6e3a30072d5be012f0c0649c579d3433f4d5ee458bdb429be) [staticcall]
│ │ │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000038d7e92ebf3d2
│ │ │ ├─ [1425] PoolManager::settle{value: 1000000000000000}()
│ │ │ │ └─ ← [Return] 1000000000000000 [1e15]
│ │ │ ├─ [10243] PoolManager::take(MockERC20: [0x15cF58144EF33af1e14b5208015d11F9143E27b9], PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 999999700464594 [9.999e14])
│ │ │ │ ├─ [8511] MockERC20::transfer(PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 999999700464594 [9.999e14])
│ │ │ │ │ ├─ emit Transfer(from: PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], to: PhaseLogicBoundaryInconsistencyTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], amount: 999999700464594 [9.999e14])
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] 0xfffffffffffffffffffc72815b398000000000000000000000038d7e92ebf3d2
│ │ └─ ← [Return] 0xfffffffffffffffffffc72815b398000000000000000000000038d7e92ebf3d2
│ └─ ← [Return] -340282366920938463463374607431768211455000000299535406 [-3.402e53]
├─ [247] TokenLaunchHook::currentPhase() [staticcall]
│ └─ ← [Return] 1
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 28.54ms (399.10µs CPU time)
Ran 1 test suite in 31.49ms (28.54ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Standardize the comparison operator across all phase computations.

  • For example, use strict < everywhere to ensure the first block after the boundary is the start of the next phase.

- // in _beforeSwap
- if (blocksSinceLaunch <= phase1Duration) { newPhase = 1; }
- else if (blocksSinceLaunch <= phase1Duration + phase2Duration) { newPhase = 2; } else { newPhase = 3; }
+ // in _beforeSwap (match getCurrentPhase)
+ if (blocksSinceLaunch < phase1Duration) { newPhase = 1; }
+ else if (blocksSinceLaunch < phase1Duration + phase2Duration) { newPhase = 2; } else { newPhase = 3; }

Support

FAQs

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

Give us feedback!