Building a v4 hook? Pick what it does, set the numbers, and deploy it as your own hook or stacked on v4 Marketplace. Open the builder ↗

Stock Hours

What it does

  • The fee follows the average price move between swaps: 0.05% when calm, plus 0.2% for each 1% the price moves on average, up to 2%.
  • From 20:00 to 14:30 UTC on weekdays and all weekend, when the stock market is closed, the fee is 1%.
  • Once the price has moved more than 5% within 15 minutes, swaps that push it further are refused until the window rolls over. Swaps that bring it back go through.
Volatility feeOff-hours feePrice guard
src/StockHours.sol, as verified on Sourcify
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

// StockHours: a v4 hook for the v4 Marketplace hook stack, written by v4 Builder (v4builder/2) from the recipe below.
// The hook stack calls it on every swap of the pools that stack it; it answers with a fee, a rebate or a veto.
//
// - The fee follows the average price move between swaps: 0.05% when calm, plus 0.2% for each 1% the
//   price moves on average, up to 2%.
// - From 20:00 to 14:30 UTC on weekdays and all weekend, when the stock market is closed, the fee is
//   1%.
// - Once the price has moved more than 5% within 15 minutes, swaps that push it further are refused
//   until the window rolls over. Swaps that bring it back go through.

import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";

/// The interface every hook listed on v4 Marketplace implements, as served at v4market.com/sdk/ILog.sol.
interface ILog {
    function onStack(PoolKey calldata key, bytes calldata cfg) external;
    function beforeSwap(address origin, PoolKey calldata key, SwapParams calldata params)
        external
        returns (uint24 feePips, uint8 rebatePct, bool veto);
    function afterSwap(PoolKey calldata key, SwapParams calldata params, BalanceDelta delta) external;
}

contract StockHours is ILog {
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    /// The v4 Marketplace hook stack (the Dam): the only caller.
    address public immutable hookStack;

    uint256 public constant CALM_FEE = 500; // 0.05%
    uint256 public constant MAX_FEE = 20000; // 2%
    uint256 public constant FEE_PER_PERCENT = 2000; // pips added for each 1% of average move
    uint256 public constant CLOSED_FEE = 10000; // 1% while the stock market is closed
    int24 public constant MAX_MOVE_TICKS = 487; // a 5% move
    uint256 public constant GUARD_WINDOW = 900; // seconds
    IPoolManager public constant POOL_MANAGER = IPoolManager(0x8366a39CC670B4001A1121B8F6A443A643e40951);
    uint8 internal constant PRICE_GUARD = 1;

    struct PoolState {
        uint64 openedAt;
        int24 lastTick;
        uint32 ewma; // 100 x the average tick move between swaps
        uint64 windowStart;
        int24 windowTick;
    }

    mapping(PoolId => PoolState) public pools;

    error NotHookStack();

    constructor(address hookStack_) {
        hookStack = hookStack_;
    }

    modifier onlyHookStack() {
        if (msg.sender != hookStack) revert NotHookStack();
        _;
    }

    /// Called once by the hook stack when a pool stacks this hook.
    function onStack(PoolKey calldata key, bytes calldata) external onlyHookStack {
        PoolId id = key.toId();
        (, int24 tick,,) = POOL_MANAGER.getSlot0(id);
        PoolState storage s = pools[id];
        if (s.openedAt != 0) return;
        s.openedAt = uint64(block.timestamp);
        s.lastTick = tick;
        s.windowStart = uint64(block.timestamp);
        s.windowTick = tick;
    }

    /// Called by the hook stack before every swap: a fee proposal in pips (0 for no opinion),
    /// a rebate in percent, and a veto. It never reverts on a rule.
    function beforeSwap(address origin, PoolKey calldata key, SwapParams calldata params)
        external
        onlyHookStack
        returns (uint24 feePips, uint8 rebatePct, bool veto)
    {
        (uint24 fee, uint8 rebate, uint8 refused) = _rules(key.toId(), origin, params.zeroForOne);
        return (fee, rebate, refused != 0);
    }

    /// Not used: this hook is listed with afterSwap off.
    function afterSwap(PoolKey calldata, SwapParams calldata, BalanceDelta) external view onlyHookStack {}

    /// The rules, in order: the refusals first (nothing is written when a swap is refused),
    /// then the bookkeeping, then the fee proposals (the highest wins) and the rebates (the largest wins).
    function _rules(PoolId id, address, bool zeroForOne)
        internal
        returns (uint24, uint8, uint8)
    {
        uint24 fee;
        PoolState storage s = pools[id];
        (, int24 tick,,) = POOL_MANAGER.getSlot0(id);
        if (block.timestamp >= s.windowStart + GUARD_WINDOW) {
            s.windowStart = uint64(block.timestamp);
            s.windowTick = tick;
        } else {
            int256 moved = int256(tick) - int256(s.windowTick);
            // A swap of token0 for token1 lowers the tick; the other way raises it.
            if ((moved > MAX_MOVE_TICKS && !zeroForOne) || (moved < -MAX_MOVE_TICKS && zeroForOne)) return (0, 0, PRICE_GUARD);
        }
        {
            int256 d = int256(tick) - int256(s.lastTick);
            uint256 move = uint256(d < 0 ? -d : d);
            uint256 e = (7 * uint256(s.ewma) + 100 * move) / 8;
            if (e > type(uint32).max) e = type(uint32).max;
            s.ewma = uint32(e);
            s.lastTick = tick;
            // e is 100 x the average move in ticks, and 100 ticks are a 1% move.
            uint256 v = CALM_FEE + FEE_PER_PERCENT * e / 10_000;
            if (v > MAX_FEE) v = MAX_FEE;
            if (v > fee) fee = uint24(v);
        }
        if (_marketClosed(block.timestamp) && CLOSED_FEE > fee) fee = uint24(CLOSED_FEE);
        return (fee, 0, 0);
    }

    /// NYSE regular hours, 14:30 to 20:00 UTC, Monday to Friday. Holidays count as open.
    function _marketClosed(uint256 t) internal pure returns (bool) {
        uint256 weekday = (t / 1 days + 4) % 7; // 0 is Sunday
        if (weekday == 0 || weekday == 6) return true;
        uint256 second = t % 1 days;
        return second < 14 hours + 30 minutes || second >= 20 hours;
    }
}

The recipe, as written in the factory's event

{"v":1,"g":"v4builder/2","name":"Stock Hours","target":"stack","baseFee":500,"modules":[{"id":"volatilityFee","p":{"calmFee":500,"maxFee":20000,"sensitivity":2000}},{"id":"offHoursFee","p":{"closedFee":10000}},{"id":"priceGuard","p":{"maxMove":500,"window":900}}]}
Start from this recipe