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