// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; /// @notice Transparent, bounded fees from completed-window quote-side turnover. /// @dev The reference is a frozen quote-denominated budget, NOT live TVL or LP inflow. library AdaptiveFeePolicy { uint24 internal constant MIN_FEE_PPM = 10_000; uint24 internal constant MAX_FEE_PPM = 180_000; uint24 internal constant MAX_STEP_PPM = 10_000; struct Policy { bool dynamicMode; uint24 minFeePpm; uint24 maxFeePpm; uint24 maxStepPpm; uint32 windowSeconds; uint32 targetTurnoverBps; uint128 referenceQuoteRaw; } struct State { uint64 windowStart; uint24 feePpm; uint128 volumeQuoteRaw; uint128 lastWindowVolumeQuoteRaw; } error InvalidFeePolicy(); error TimestampOutOfRange(); function validate(Policy memory policy) internal pure { if (policy.minFeePpm < MIN_FEE_PPM || policy.maxFeePpm > MAX_FEE_PPM || policy.minFeePpm > policy.maxFeePpm) revert InvalidFeePolicy(); if (!policy.dynamicMode) { if (policy.minFeePpm != policy.maxFeePpm || policy.maxStepPpm != 0 || policy.windowSeconds != 0 || policy.targetTurnoverBps != 0 || policy.referenceQuoteRaw != 0) revert InvalidFeePolicy(); } else if (policy.minFeePpm == policy.maxFeePpm || policy.maxStepPpm == 0 || policy.maxStepPpm > MAX_STEP_PPM || policy.windowSeconds < 60 || policy.windowSeconds > 3600 || policy.targetTurnoverBps < 100 || policy.targetTurnoverBps > 10_000 || policy.referenceQuoteRaw == 0) { revert InvalidFeePolicy(); } } function checkedTimestamp(uint256 timestamp) internal pure returns (uint64) { if (timestamp > type(uint64).max) revert TimestampOutOfRange(); return uint64(timestamp); } function initial(Policy memory policy, uint256 timestamp) internal pure returns (State memory state) { state.windowStart = policy.dynamicMode ? checkedTimestamp(timestamp) : 0; state.feePpm = policy.minFeePpm; } /// @dev Rounded upward and capped by uint128 because turnover is at most 100%. function volumeTarget(Policy memory policy) internal pure returns (uint128) { return uint128((uint256(policy.referenceQuoteRaw) * policy.targetTurnoverBps + 9999) / 10_000); } function approach(uint24 current, uint24 target, uint24 step) internal pure returns (uint24) { if (current < target) return target - current > step ? current + step : target; return current - target > step ? current - step : target; } /// @notice Same projection for onchain execution and read-only previews, with no loops. function project(Policy memory policy, State memory state, uint256 timestamp) internal pure returns (State memory) { if (!policy.dynamicMode) return initial(policy, timestamp); uint64 currentTime = checkedTimestamp(timestamp); if (currentTime < state.windowStart) revert TimestampOutOfRange(); uint256 elapsed = (currentTime - state.windowStart) / policy.windowSeconds; if (elapsed == 0) return state; uint256 volume = state.volumeQuoteRaw; uint256 cap = volumeTarget(policy); if (volume > cap) volume = cap; uint24 target = policy.minFeePpm + uint24(uint256(policy.maxFeePpm - policy.minFeePpm) * volume / cap); state.feePpm = approach(state.feePpm, target, policy.maxStepPpm); // Only the immediately previous completed window can contain observed volume. // Older empty windows decay directly in O(1), never replaying old volume. if (elapsed > 1) { uint256 decay = (elapsed - 1) * uint256(policy.maxStepPpm); uint256 excess = state.feePpm - policy.minFeePpm; state.feePpm = decay >= excess ? policy.minFeePpm : state.feePpm - uint24(decay); } state.lastWindowVolumeQuoteRaw = elapsed == 1 ? uint128(volume) : 0; state.volumeQuoteRaw = 0; state.windowStart += uint64(elapsed * policy.windowSeconds); return state; } /// @dev Saturation prevents wash volume or long histories overflowing the accumulator. /// Count only successful swaps, after their own fee was selected; never count donations/LP. function observe(Policy memory policy, State memory state, uint256 quoteAmount) internal pure returns (State memory) { if (!policy.dynamicMode) return state; uint128 cap = volumeTarget(policy); uint256 remaining = cap - state.volumeQuoteRaw; state.volumeQuoteRaw += uint128(quoteAmount >= remaining ? remaining : quoteAmount); return state; } }