From d984968da5b0c111da6f9440603bd9a25e508a17 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 6 Apr 2026 17:55:45 -0400 Subject: [PATCH 01/26] feat: add V4 fee adapter with policy-based waterfall and automated hook classification Introduces a 2-contract architecture for Uniswap v4 protocol fees: - V4FeeAdapter: protocolFeeController registered on PoolManager. Resolves fees via waterfall (pool override -> policy -> 0), triggers fee updates permissionlessly, and collects accrued fees to TokenJar. - V4FeePolicy: replaceable policy brain with automated hook classification from address bits 0-3 (RETURNS_DELTA flags), a piecewise-constant baseline curve for static NativeMath pools, governance-assigned uint8 family IDs for custom-accounting/dynamic-fee hooks, per-family multipliers over pair fees, and a configurable default fee fallback. Hooks can self-report their family via IFeeClassifiedHook (gas-capped staticcall, governance override always wins). - IFeeClassifiedHook: optional interface for hooks to self-report their protocol fee family without requiring a governance transaction. Sentinel encoding (type(uint24).max) distinguishes explicit zero fees from unset values. Each setter has a corresponding clear function for removing config and falling through in the waterfall. Includes 74 unit tests against a storage-layout-correct MockV4PoolManager with extsload support, and 11 integration tests using the real v4 PoolManager (via v4-core Deployers) with actual swaps that accrue and collect protocol fees. Gas snapshots via vm.snapshotGasLastCall. Also switches foundry.toml from pinned solc 0.8.29 to auto_detect_solc to support v4-core's PoolManager (pragma solidity 0.8.26) in the same project. --- foundry.toml | 2 +- snapshots/V4FeeAdapterForkTest.json | 6 + snapshots/V4FeeAdapterTest.json | 19 + src/feeAdapters/V4FeeAdapter.sol | 163 ++++ src/feeAdapters/V4FeePolicy.sol | 321 ++++++++ src/interfaces/IFeeClassifiedHook.sol | 16 + src/interfaces/IV4FeeAdapter.sol | 143 ++++ src/interfaces/IV4FeePolicy.sol | 237 ++++++ test/V4FeeAdapter.fork.t.sol | 399 ++++++++++ test/V4FeeAdapter.t.sol | 1015 +++++++++++++++++++++++++ test/mocks/MockFeeClassifiedHook.sol | 32 + test/mocks/MockV4PoolManager.sol | 147 ++++ 12 files changed, 2499 insertions(+), 1 deletion(-) create mode 100644 snapshots/V4FeeAdapterForkTest.json create mode 100644 snapshots/V4FeeAdapterTest.json create mode 100644 src/feeAdapters/V4FeeAdapter.sol create mode 100644 src/feeAdapters/V4FeePolicy.sol create mode 100644 src/interfaces/IFeeClassifiedHook.sol create mode 100644 src/interfaces/IV4FeeAdapter.sol create mode 100644 src/interfaces/IV4FeePolicy.sol create mode 100644 test/V4FeeAdapter.fork.t.sol create mode 100644 test/V4FeeAdapter.t.sol create mode 100644 test/mocks/MockFeeClassifiedHook.sol create mode 100644 test/mocks/MockV4PoolManager.sol diff --git a/foundry.toml b/foundry.toml index 2b71bf7c..a285e9bd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,7 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 -solc_version = "0.8.29" +auto_detect_solc = true verbosity = 3 gas_limit = "9223372036854775807" no_match_test = "test_fuzz_gas_release_malicious" diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json new file mode 100644 index 00000000..1ad14705 --- /dev/null +++ b/snapshots/V4FeeAdapterForkTest.json @@ -0,0 +1,6 @@ +{ + "fork: batchTriggerFeeUpdate 3 pools": "99787", + "fork: collect 2 currencies": "99567", + "fork: collect single currency": "62947", + "fork: triggerFeeUpdate single pool": "57915" +} \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json new file mode 100644 index 00000000..e6d9525e --- /dev/null +++ b/snapshots/V4FeeAdapterTest.json @@ -0,0 +1,19 @@ +{ + "adapter.batchTriggerFeeUpdate - two pools": "78027", + "adapter.collect - single currency": "58075", + "adapter.getFee - pool override hit": "3329", + "adapter.setPoolOverride": "48080", + "adapter.triggerFeeUpdate - single pool": "57855", + "policy.computeFee - classified family default": "10049", + "policy.computeFee - classified griefing hook -> defaultFee": "38465", + "policy.computeFee - classified pairFee * multiplier": "8317", + "policy.computeFee - classified self-report": "13414", + "policy.computeFee - classified unclassified -> defaultFee": "6075", + "policy.computeFee - static native math baseline curve": "10840", + "policy.computeFee - static native math pair fee": "3540", + "policy.setBaselineCurve - four breakpoints": "141904", + "policy.setFamilyDefault": "47814", + "policy.setFamilyMultiplier": "47586", + "policy.setHookFamily": "47567", + "policy.setPairFee": "48696" +} \ No newline at end of file diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol new file mode 100644 index 00000000..97e898d9 --- /dev/null +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; +import {IV4FeeAdapter} from "../interfaces/IV4FeeAdapter.sol"; +import {IV4FeePolicy} from "../interfaces/IV4FeePolicy.sol"; + +/// @title V4FeeAdapter +/// @notice The protocolFeeController for the Uniswap V4 PoolManager. Resolves fees via a +/// waterfall (pool override → policy → 0), pushes them to the PoolManager, and collects +/// accrued fees to the TokenJar. +/// @dev The adapter is the trusted, long-lived piece. The policy is replaceable by the owner. +/// @custom:security-contact security@uniswap.org +contract V4FeeAdapter is IV4FeeAdapter, Owned { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + /// @dev Sentinel value: stored to represent an explicit zero fee. type(uint24).max is + /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). + uint24 public constant ZERO_FEE_SENTINEL = type(uint24).max; + + /// @inheritdoc IV4FeeAdapter + IPoolManager public immutable POOL_MANAGER; + + /// @inheritdoc IV4FeeAdapter + address public immutable TOKEN_JAR; + + /// @inheritdoc IV4FeeAdapter + address public feeSetter; + + /// @inheritdoc IV4FeeAdapter + IV4FeePolicy public policy; + + /// @inheritdoc IV4FeeAdapter + mapping(PoolId poolId => uint24) public poolOverrides; + + /// @notice Restricts access to the fee setter address. + modifier onlyFeeSetter() { + if (msg.sender != feeSetter) revert Unauthorized(); + _; + } + + /// @notice Constructs the V4FeeAdapter with immutable references to the PoolManager and + /// TokenJar. The deployer becomes the initial owner. + /// @param poolManager The Uniswap V4 PoolManager this adapter is the protocolFeeController + /// for. Must be registered via PoolManager.setProtocolFeeController() after deployment. + /// @param tokenJar The address where all collected protocol fees are sent. + constructor(IPoolManager poolManager, address tokenJar) Owned(msg.sender) { + POOL_MANAGER = poolManager; + TOKEN_JAR = tokenJar; + } + + // ─── Fee Resolution ─── + + /// @inheritdoc IV4FeeAdapter + function getFee(PoolKey memory key) public view returns (uint24) { + uint24 stored = poolOverrides[key.toId()]; + if (stored != 0) return _decodeFee(stored); + if (address(policy) == address(0)) return 0; + return policy.computeFee(key); + } + + // ─── Permissionless Triggering ─── + + /// @inheritdoc IV4FeeAdapter + function triggerFeeUpdate(PoolKey calldata key) external { + _setProtocolFee(key); + } + + /// @inheritdoc IV4FeeAdapter + function batchTriggerFeeUpdate(PoolKey[] calldata keys) external { + for (uint256 i; i < keys.length; ++i) { + _setProtocolFee(keys[i]); + } + } + + // ─── Collection ─── + + /// @inheritdoc IV4FeeAdapter + function collect(CollectParams[] calldata params) external { + uint256 length = params.length; + for (uint256 i; i < length; ++i) { + CollectParams calldata p = params[i]; + uint256 collected = POOL_MANAGER.collectProtocolFees(TOKEN_JAR, p.currency, p.amount); + emit FeesCollected(p.currency, collected); + } + } + + // ─── Admin (onlyOwner) ─── + + /// @inheritdoc IV4FeeAdapter + function setPolicy(IV4FeePolicy newPolicy) external onlyOwner { + emit PolicyUpdated(address(policy), address(newPolicy)); + policy = newPolicy; + } + + /// @inheritdoc IV4FeeAdapter + function setFeeSetter(address newFeeSetter) external onlyOwner { + emit FeeSetterUpdated(feeSetter, newFeeSetter); + feeSetter = newFeeSetter; + } + + // ─── Pool Overrides (onlyFeeSetter) ─── + + /// @inheritdoc IV4FeeAdapter + function setPoolOverride(PoolId poolId, uint24 feeValue) external onlyFeeSetter { + if (feeValue != 0) _validateFee(feeValue); + poolOverrides[poolId] = _encodeFee(feeValue); + emit PoolOverrideUpdated(poolId, feeValue); + } + + /// @inheritdoc IV4FeeAdapter + function clearPoolOverride(PoolId poolId) external onlyFeeSetter { + delete poolOverrides[poolId]; + emit PoolOverrideUpdated(poolId, 0); + } + + // ─── Internal ─── + + /// @dev Resolves the fee for a pool via the waterfall, checks that the pool is + /// initialized, and pushes the fee to the PoolManager. Silently skips uninitialized + /// pools (sqrtPriceX96 == 0) to avoid a revert from the PoolManager and save gas. + /// @param key The pool key identifying the pool to update. + function _setProtocolFee(PoolKey memory key) internal { + PoolId id = key.toId(); + + // Check pool is initialized (sqrtPriceX96 != 0) before calling PoolManager + (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(id); + if (sqrtPriceX96 == 0) return; + + uint24 feeValue = getFee(key); + POOL_MANAGER.setProtocolFee(key, feeValue); + emit FeeUpdateTriggered(msg.sender, id, feeValue); + } + + /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in + /// storage means "not set" rather than "explicitly zero". + /// @param feeValue The actual fee value (0 = remove/unset). + /// @return The encoded value to store. + function _encodeFee(uint24 feeValue) internal pure returns (uint24) { + return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; + } + + /// @dev Decodes a fee from storage. Converts ZERO_FEE_SENTINEL back to 0. + /// @param stored The raw value from storage. + /// @return The actual fee value. + function _decodeFee(uint24 stored) internal pure returns (uint24) { + return stored == ZERO_FEE_SENTINEL ? 0 : stored; + } + + /// @dev Validates that a protocol fee is within v4-core bounds (each 12-bit directional + /// component must be <= MAX_PROTOCOL_FEE = 1000). + /// @param feeValue The fee to validate. + function _validateFee(uint24 feeValue) internal pure { + if (!ProtocolFeeLibrary.isValidProtocolFee(feeValue)) revert InvalidFeeValue(); + } +} diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol new file mode 100644 index 00000000..d8a9a0ca --- /dev/null +++ b/src/feeAdapters/V4FeePolicy.sol @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {IV4FeePolicy, CurveBreakpoint} from "../interfaces/IV4FeePolicy.sol"; +import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; + +/// @title V4FeePolicy +/// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification +/// and a baseline fee curve. +/// @dev Pools are classified into two paths: +/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → baseline curve or pair fee. +/// - Classified: custom accounting or dynamic fee → family multiplier × pair fee, family +/// default, or global default fee. +/// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). +/// Hooks can also self-report their family via IFeeClassifiedHook.protocolFeeFamily(). +/// Priority: governance override → hook self-report → defaultFee. +/// @custom:security-contact security@uniswap.org +contract V4FeePolicy is IV4FeePolicy, Owned { + using LPFeeLibrary for uint24; + using PoolIdLibrary for PoolKey; + + /// @dev Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). + uint160 public constant CUSTOM_ACCOUNTING_MASK = 0xF; + + /// @dev Gas limit for hook self-report calls. Prevents griefing in batch operations. + uint256 internal constant SELF_REPORT_GAS_LIMIT = 30_000; + + /// @dev Sentinel value: stored to represent an explicit zero fee. type(uint24).max is + /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). + uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; + + /// @inheritdoc IV4FeePolicy + IPoolManager public immutable POOL_MANAGER; + + /// @inheritdoc IV4FeePolicy + address public feeSetter; + + /// @inheritdoc IV4FeePolicy + uint24 public defaultFee; + + /// @inheritdoc IV4FeePolicy + mapping(address hook => uint8) public hookFamilyId; + + /// @inheritdoc IV4FeePolicy + mapping(uint8 familyId => uint24) public familyDefaults; + + /// @inheritdoc IV4FeePolicy + mapping(uint8 familyId => uint16) public familyMultiplierBps; + + /// @inheritdoc IV4FeePolicy + mapping(bytes32 pairHash => uint24) public pairFees; + + /// @dev The baseline curve breakpoints, sorted ascending by lpFeeFloor. Used only by + /// StaticNativeMath pools to map key.fee to a protocol fee. + CurveBreakpoint[] internal _baselineCurve; + + /// @notice Restricts access to the fee setter address. + modifier onlyFeeSetter() { + if (msg.sender != feeSetter) revert Unauthorized(); + _; + } + + /// @notice Constructs the V4FeePolicy with a reference to the PoolManager. + /// @param poolManager The Uniswap V4 PoolManager this policy reads state from. + constructor(IPoolManager poolManager) Owned(msg.sender) { + POOL_MANAGER = poolManager; + } + + // ─── Pure Classification ─── + + /// @inheritdoc IV4FeePolicy + function isCustomAccounting(address hook) external pure returns (bool) { + return _isCustomAccounting(hook); + } + + // ─── Fee Computation ─── + + /// @inheritdoc IV4FeePolicy + function computeFee(PoolKey calldata key) external view returns (uint24) { + address hook = address(key.hooks); + bytes32 ph = _pairHash(key.currency0, key.currency1); + + // StaticNativeMath: no custom accounting + static fee + if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) { + uint24 stored = pairFees[ph]; + return stored != 0 ? _decodeFee(stored) : _lookupBaselineFee(key.fee); + } + + // Classified: custom accounting OR dynamic fee + // Priority: governance override → hook self-report → unclassified + uint8 family = _resolveFamily(hook); + if (family != 0) { + uint24 pairFee = pairFees[ph]; + uint16 multiplier = familyMultiplierBps[family]; + + if (pairFee != 0 && multiplier != 0) { + return _applyMultiplier(_decodeFee(pairFee), multiplier); + } + + uint24 famDefault = familyDefaults[family]; + if (famDefault != 0) return _decodeFee(famDefault); + } + + return _decodeFee(defaultFee); + } + + // ─── Curve Getters ─── + + /// @inheritdoc IV4FeePolicy + function baselineCurveLength() external view returns (uint256) { + return _baselineCurve.length; + } + + /// @inheritdoc IV4FeePolicy + function baselineCurve(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 protocolFee) + { + CurveBreakpoint storage bp = _baselineCurve[index]; + return (bp.lpFeeFloor, bp.protocolFee); + } + + // ─── Admin ─── + + /// @inheritdoc IV4FeePolicy + function setFeeSetter(address newFeeSetter) external onlyOwner { + emit FeeSetterUpdated(feeSetter, newFeeSetter); + feeSetter = newFeeSetter; + } + + // ─── Configuration (onlyFeeSetter) ─── + + /// @inheritdoc IV4FeePolicy + function setHookFamily(address hook, uint8 familyId) external onlyFeeSetter { + hookFamilyId[hook] = familyId; + emit HookFamilySet(hook, familyId); + } + + /// @inheritdoc IV4FeePolicy + function setDefaultFee(uint24 feeValue) external onlyFeeSetter { + if (feeValue != 0) _validateFee(feeValue); + defaultFee = _encodeFee(feeValue); + emit DefaultFeeUpdated(feeValue); + } + + /// @inheritdoc IV4FeePolicy + function clearDefaultFee() external onlyFeeSetter { + delete defaultFee; + emit DefaultFeeUpdated(0); + } + + /// @inheritdoc IV4FeePolicy + function setBaselineCurve(CurveBreakpoint[] calldata breakpoints) external onlyFeeSetter { + if (breakpoints.length == 0) revert EmptyCurve(); + + // Clear existing curve + delete _baselineCurve; + + uint24 prevFloor; + for (uint256 i; i < breakpoints.length; ++i) { + CurveBreakpoint calldata bp = breakpoints[i]; + if (i != 0 && bp.lpFeeFloor <= prevFloor) revert CurveNotAscending(); + _validateFee(bp.protocolFee); + _baselineCurve.push(bp); + prevFloor = bp.lpFeeFloor; + } + + emit BaselineCurveUpdated(breakpoints.length); + } + + /// @inheritdoc IV4FeePolicy + function setFamilyDefault(uint8 familyId, uint24 feeValue) external onlyFeeSetter { + if (familyId == 0) revert InvalidFamilyId(); + if (feeValue != 0) _validateFee(feeValue); + familyDefaults[familyId] = _encodeFee(feeValue); + emit FamilyDefaultUpdated(familyId, feeValue); + } + + /// @inheritdoc IV4FeePolicy + function clearFamilyDefault(uint8 familyId) external onlyFeeSetter { + delete familyDefaults[familyId]; + emit FamilyDefaultUpdated(familyId, 0); + } + + /// @inheritdoc IV4FeePolicy + function setFamilyMultiplier(uint8 familyId, uint16 multiplierBps) external onlyFeeSetter { + if (familyId == 0) revert InvalidFamilyId(); + familyMultiplierBps[familyId] = multiplierBps; + emit FamilyMultiplierUpdated(familyId, multiplierBps); + } + + /// @inheritdoc IV4FeePolicy + function clearFamilyMultiplier(uint8 familyId) external onlyFeeSetter { + delete familyMultiplierBps[familyId]; + emit FamilyMultiplierUpdated(familyId, 0); + } + + /// @inheritdoc IV4FeePolicy + function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) + external + onlyFeeSetter + { + if (Currency.unwrap(currency0) >= Currency.unwrap(currency1)) { + revert CurrenciesOutOfOrder(); + } + if (feeValue != 0) _validateFee(feeValue); + bytes32 ph = _pairHash(currency0, currency1); + pairFees[ph] = _encodeFee(feeValue); + emit PairFeeUpdated(ph, feeValue); + } + + /// @inheritdoc IV4FeePolicy + function clearPairFee(Currency currency0, Currency currency1) external onlyFeeSetter { + if (Currency.unwrap(currency0) >= Currency.unwrap(currency1)) revert CurrenciesOutOfOrder(); + bytes32 ph = _pairHash(currency0, currency1); + delete pairFees[ph]; + emit PairFeeUpdated(ph, 0); + } + + // ─── Internal ─── + + /// @dev Returns true if the hook address has any RETURNS_DELTA flag set (bits 0-3). + /// This is a pure function of the address — the flags are baked into the address at + /// CREATE2 deployment time and cannot change. + /// @param hook The hook contract address to check. + /// @return True if any of the four RETURNS_DELTA bits are set. + function _isCustomAccounting(address hook) internal pure returns (bool) { + return uint160(hook) & CUSTOM_ACCOUNTING_MASK != 0; + } + + /// @dev Resolves the family ID for a hook using a priority chain: + /// 1. Governance override (hookFamilyId[hook]) — always wins if non-zero. + /// 2. Hook self-report via IFeeClassifiedHook.protocolFeeFamily() — gas-capped + /// staticcall at SELF_REPORT_GAS_LIMIT. Trusted if call succeeds and returns non-zero. + /// 3. Returns 0 (unclassified) if neither source provides a family. + /// @param hook The hook contract address to resolve. + /// @return The resolved family ID, or 0 if unclassified. + function _resolveFamily(address hook) internal view returns (uint8) { + uint8 gov = hookFamilyId[hook]; + if (gov != 0) return gov; + + // Ask the hook if it self-reports a family (gas-capped to prevent griefing) + (bool ok, bytes memory ret) = hook.staticcall{gas: SELF_REPORT_GAS_LIMIT}( + abi.encodeCall(IFeeClassifiedHook.protocolFeeFamily, ()) + ); + if (ok && ret.length >= 32) { + uint8 reported = abi.decode(ret, (uint8)); + if (reported != 0) return reported; + } + + return 0; + } + + /// @dev Computes a canonical hash for a token pair. Assumes c0 < c1 (guaranteed by + /// PoolKey sorting invariant). Used as the key for pairFees lookups. + /// @param c0 The lower currency address. + /// @param c1 The higher currency address. + /// @return The keccak256 hash of the packed currency addresses. + function _pairHash(Currency c0, Currency c1) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(Currency.unwrap(c0), Currency.unwrap(c1))); + } + + /// @dev Walks the baseline curve backward to find the highest floor <= lpFee. + /// Returns 0 if the curve is empty or no breakpoint qualifies. + /// @param lpFee The pool's LP fee in pips (from key.fee for static fee pools). + /// @return The protocol fee from the matching breakpoint, or 0. + function _lookupBaselineFee(uint24 lpFee) internal view returns (uint24) { + uint256 len = _baselineCurve.length; + if (len == 0) return 0; + + for (uint256 i = len; i != 0; --i) { + CurveBreakpoint storage bp = _baselineCurve[i - 1]; + if (bp.lpFeeFloor <= lpFee) return bp.protocolFee; + } + return 0; + } + + /// @dev Scales each 12-bit directional fee component by a basis-point multiplier, + /// clamping each to MAX_PROTOCOL_FEE (1000). The two 12-bit components are extracted, + /// scaled independently, and repacked into a single uint24. + /// @param baseFee The base protocol fee (two 12-bit directional components packed). + /// @param multiplierBps The multiplier in basis points (10000 = 1x, 5000 = 0.5x). + /// @return The scaled and clamped protocol fee. + function _applyMultiplier(uint24 baseFee, uint16 multiplierBps) internal pure returns (uint24) { + uint256 fee0 = uint256(baseFee & 0xFFF) * multiplierBps / 10_000; + uint256 fee1 = uint256(baseFee >> 12) * multiplierBps / 10_000; + if (fee0 > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) fee0 = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; + if (fee1 > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) fee1 = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; + return uint24((fee1 << 12) | fee0); + } + + /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in + /// storage means "not set" rather than "explicitly zero". + /// @param feeValue The actual fee value (0 = remove/unset). + /// @return The encoded value to store. + function _encodeFee(uint24 feeValue) internal pure returns (uint24) { + return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; + } + + /// @dev Decodes a fee from storage. Converts ZERO_FEE_SENTINEL back to 0. + /// @param stored The raw value from storage. + /// @return The actual fee value. + function _decodeFee(uint24 stored) internal pure returns (uint24) { + return stored == ZERO_FEE_SENTINEL ? 0 : stored; + } + + /// @dev Validates that a protocol fee is within v4-core bounds (each 12-bit directional + /// component must be <= MAX_PROTOCOL_FEE = 1000). + /// @param feeValue The fee to validate. + function _validateFee(uint24 feeValue) internal pure { + if (!ProtocolFeeLibrary.isValidProtocolFee(feeValue)) revert InvalidFeeValue(); + } +} diff --git a/src/interfaces/IFeeClassifiedHook.sol b/src/interfaces/IFeeClassifiedHook.sol new file mode 100644 index 00000000..3aaeac7e --- /dev/null +++ b/src/interfaces/IFeeClassifiedHook.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +/// @title IFeeClassifiedHook +/// @notice Optional interface for v4 hooks to self-report their protocol fee family. +/// @dev Hooks that implement this allow the V4FeePolicy to automatically classify them +/// without requiring a governance transaction. The policy calls protocolFeeFamily() with +/// a gas cap; if the call succeeds and returns a valid familyId, it is trusted. +/// Governance can always override via setHookFamily(). +/// @custom:security-contact security@uniswap.org +interface IFeeClassifiedHook { + /// @notice Returns the hook's self-reported fee family ID. + /// @dev Return 0 to indicate no self-classification (falls through to defaultFee). + /// Family IDs (1-255) are governance-defined — see documentation for semantics. + function protocolFeeFamily() external view returns (uint8); +} diff --git a/src/interfaces/IV4FeeAdapter.sol b/src/interfaces/IV4FeeAdapter.sol new file mode 100644 index 00000000..614970da --- /dev/null +++ b/src/interfaces/IV4FeeAdapter.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId} from "v4-core/types/PoolId.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {IV4FeePolicy} from "./IV4FeePolicy.sol"; + +/// @title IV4FeeAdapter +/// @notice Interface for the V4 fee adapter — the protocolFeeController registered on the +/// PoolManager. Resolves fees via pool overrides or policy delegation, triggers fee updates, +/// and collects accrued fees to the TokenJar. +/// @custom:security-contact security@uniswap.org +interface IV4FeeAdapter { + // --- Errors --- + + /// @notice Thrown when an unauthorized address calls a restricted function. + error Unauthorized(); + + /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. + error InvalidFeeValue(); + + // --- Events --- + + /// @notice Emitted when the policy contract is updated. + /// @param oldPolicy The previous policy address. + /// @param newPolicy The new policy address. + event PolicyUpdated(address indexed oldPolicy, address indexed newPolicy); + + /// @notice Emitted when a pool override is set or removed. + /// @param poolId The pool whose override changed. + /// @param feeValue The new fee value (0 means override was removed). + event PoolOverrideUpdated(PoolId indexed poolId, uint24 feeValue); + + /// @notice Emitted when the fee setter address is updated. + /// @param oldFeeSetter The previous fee setter address. + /// @param newFeeSetter The new fee setter address. + event FeeSetterUpdated(address indexed oldFeeSetter, address indexed newFeeSetter); + + /// @notice Emitted when a fee update is triggered for a pool. + /// @param caller The address that triggered the update. + /// @param poolId The pool that was updated. + /// @param feeValue The protocol fee that was set on the PoolManager. + event FeeUpdateTriggered(address indexed caller, PoolId indexed poolId, uint24 feeValue); + + /// @notice Emitted when protocol fees are collected. + /// @param currency The currency that was collected. + /// @param amount The amount collected. + event FeesCollected(Currency indexed currency, uint256 amount); + + // --- Structs --- + + /// @notice Parameters for collecting protocol fees. + struct CollectParams { + /// @dev The currency to collect. + Currency currency; + /// @dev The amount to collect. 0 = collect all accrued. + uint256 amount; + } + + // --- Immutables --- + + /// @notice The Uniswap V4 PoolManager this adapter controls fees for. + /// @return The PoolManager contract. + function POOL_MANAGER() external view returns (IPoolManager); + + /// @notice The address where collected fees are sent. + /// @return The TokenJar address. + function TOKEN_JAR() external view returns (address); + + /// @notice Sentinel value representing an explicit zero fee in storage. + /// @dev type(uint24).max — safe because isValidProtocolFee rejects it. + /// @return The sentinel value (0xFFFFFF). + function ZERO_FEE_SENTINEL() external pure returns (uint24); + + // --- State --- + + /// @notice The address authorized to set pool overrides. + /// @return The current fee setter address. + function feeSetter() external view returns (address); + + /// @notice The current fee policy contract. + /// @return The policy contract address. + function policy() external view returns (IV4FeePolicy); + + /// @notice Returns the pool-specific fee override. + /// @param poolId The pool to query. + /// @return The sentinel-encoded fee override. 0 = not set. + function poolOverrides(PoolId poolId) external view returns (uint24); + + // --- Admin (onlyOwner) --- + + /// @notice Sets the fee policy contract. Only callable by owner. + /// @dev Setting address(0) disables policy — all non-overridden pools get fee 0. + /// @param newPolicy The new policy contract address. + function setPolicy(IV4FeePolicy newPolicy) external; + + /// @notice Sets the fee setter address. Only callable by owner. + /// @param newFeeSetter The new fee setter address. + function setFeeSetter(address newFeeSetter) external; + + // --- Pool Overrides (onlyFeeSetter) --- + + /// @notice Sets a pool-specific fee override (highest priority in waterfall). + /// @dev Setting 0 sets an explicit zero fee (does NOT fall through to policy). + /// Use clearPoolOverride to remove the override entirely. + /// @param poolId The pool to override. + /// @param feeValue The protocol fee to set. Must pass isValidProtocolFee if non-zero. + function setPoolOverride(PoolId poolId, uint24 feeValue) external; + + /// @notice Removes a pool-specific fee override, falling through to policy. + /// @param poolId The pool to clear the override for. + function clearPoolOverride(PoolId poolId) external; + + // --- Fee Resolution --- + + /// @notice Resolves the protocol fee for a pool: pool override → policy → 0. + /// @param key The pool key to resolve the fee for. + /// @return fee The resolved protocol fee. + function getFee(PoolKey memory key) external view returns (uint24 fee); + + // --- Permissionless Triggering --- + + /// @notice Triggers a fee update for a single pool. Permissionless. + /// @dev Silently skips uninitialized pools (sqrtPriceX96 == 0). + /// @param key The pool key to update. + function triggerFeeUpdate(PoolKey calldata key) external; + + /// @notice Triggers fee updates for multiple pools. Permissionless. + /// @dev Silently skips uninitialized pools. + /// @param keys The pool keys to update. + function batchTriggerFeeUpdate(PoolKey[] calldata keys) external; + + // --- Collection --- + + /// @notice Collects protocol fees to the TOKEN_JAR. Permissionless. + /// @dev Safe because funds always go to the immutable TOKEN_JAR. The PoolManager + /// enforces that only the protocolFeeController (this contract) can call + /// collectProtocolFees. + /// @param params Array of currencies and amounts to collect. + function collect(CollectParams[] calldata params) external; +} diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol new file mode 100644 index 00000000..e289ded6 --- /dev/null +++ b/src/interfaces/IV4FeePolicy.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {Currency} from "v4-core/types/Currency.sol"; + +/// @dev A breakpoint in the baseline curve. Protocol fee is applied for pools whose LP fee +/// is >= lpFeeFloor. Breakpoints must be stored in ascending order of lpFeeFloor. +struct CurveBreakpoint { + /// @dev The minimum LP fee (in pips) for this breakpoint to apply. Pools with an LP fee + /// >= this value and < the next breakpoint's floor receive this breakpoint's protocolFee. + uint24 lpFeeFloor; + /// @dev The protocol fee to apply. Packed as two 12-bit directional components: + /// lower 12 bits = 0→1 fee, upper 12 bits = 1→0 fee. Each must be <= MAX_PROTOCOL_FEE. + uint24 protocolFee; +} + +/// @title IV4FeePolicy +/// @notice Interface for the V4 fee policy contract that computes protocol fees based on +/// automated hook classification and governance-configured parameters. +/// @dev Hook family IDs are governance-assigned uint8 values (1-255). 0 = unclassified. +/// Family IDs have no hardcoded semantic meaning — labels live in offchain documentation. +/// Static NativeMath pools bypass classification and use the baseline curve directly. +/// Custom-accounting hooks and dynamic fee pools require a governance-assigned familyId. +/// @custom:security-contact security@uniswap.org +interface IV4FeePolicy { + // --- Errors --- + + /// @notice Thrown when an unauthorized address calls a restricted function. + error Unauthorized(); + + /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. + error InvalidFeeValue(); + + /// @notice Thrown when familyId == 0 is passed to a function that requires > 0. + error InvalidFamilyId(); + + /// @notice Thrown when setBaselineCurve is called with an empty array. + error EmptyCurve(); + + /// @notice Thrown when baseline curve breakpoints are not in strictly ascending order. + error CurveNotAscending(); + + /// @notice Thrown when currency0 >= currency1 in setPairFee. + error CurrenciesOutOfOrder(); + + // --- Events --- + + /// @notice Emitted when the fee setter address is updated. + /// @param oldFeeSetter The previous fee setter address. + /// @param newFeeSetter The new fee setter address. + event FeeSetterUpdated(address indexed oldFeeSetter, address indexed newFeeSetter); + + /// @notice Emitted when a hook's family classification is set or cleared. + /// @param hook The hook address that was classified. + /// @param familyId The assigned family ID (0 = unclassified). + event HookFamilySet(address indexed hook, uint8 familyId); + + /// @notice Emitted when a family's default protocol fee is updated. + /// @param familyId The family whose default was changed. + /// @param feeValue The new default fee (0 = removed). + event FamilyDefaultUpdated(uint8 indexed familyId, uint24 feeValue); + + /// @notice Emitted when a family's multiplier is updated. + /// @param familyId The family whose multiplier was changed. + /// @param multiplierBps The new multiplier in basis points (0 = removed). + event FamilyMultiplierUpdated(uint8 indexed familyId, uint16 multiplierBps); + + /// @notice Emitted when a pair fee is updated. + /// @param pairHash The canonical hash of the token pair. + /// @param feeValue The new pair fee (0 = removed). + event PairFeeUpdated(bytes32 indexed pairHash, uint24 feeValue); + + /// @notice Emitted when the baseline curve is replaced. + /// @param breakpointCount The number of breakpoints in the new curve. + event BaselineCurveUpdated(uint256 breakpointCount); + + /// @notice Emitted when the default classified fee is updated. + /// @param feeValue The new default fee (0 = removed). + event DefaultFeeUpdated(uint24 feeValue); + + // --- Constants --- + + /// @notice Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). + /// @dev BEFORE_SWAP_RETURNS_DELTA (bit 3) | AFTER_SWAP_RETURNS_DELTA (bit 2) | + /// AFTER_ADD_LIQUIDITY_RETURNS_DELTA (bit 1) | AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA (bit 0) + /// @return The bitmask value (0xF). + function CUSTOM_ACCOUNTING_MASK() external pure returns (uint160); + + // --- Immutables --- + + /// @notice The Uniswap V4 PoolManager this policy reads state from. + /// @return The PoolManager contract. + function POOL_MANAGER() external view returns (IPoolManager); + + // --- State --- + + /// @notice The address authorized to configure fees. + /// @return The current fee setter address. + function feeSetter() external view returns (address); + + /// @notice Fallback fee for all classified pools when no family-specific config applies. + /// @dev Also used for unclassified hooks (familyId == 0). Sentinel-encoded in storage. + /// @return The sentinel-encoded default fee. + function defaultFee() external view returns (uint24); + + /// @notice Returns the governance-assigned family ID for a hook. + /// @dev 0 = unclassified. StaticNativeMath pools bypass this entirely. + /// @param hook The hook address to query. + /// @return The family ID (0-255). + function hookFamilyId(address hook) external view returns (uint8); + + /// @notice Returns the default protocol fee for a given family ID. + /// @param familyId The family to query. + /// @return The sentinel-encoded default fee for the family. + function familyDefaults(uint8 familyId) external view returns (uint24); + + /// @notice Returns the multiplier (basis points) for a given family ID. + /// @dev 10000 = 1x, 5000 = 0.5x. Applied to pairFees to derive a scaled fee. + /// @param familyId The family to query. + /// @return The multiplier in basis points (0 = not set). + function familyMultiplierBps(uint8 familyId) external view returns (uint16); + + /// @notice Returns the pair fee for a token pair hash. + /// @dev Flat mapping — one fee per pair. StaticNativeMath uses it directly (overrides + /// baseline curve). Classified pools scale it by the family multiplier. + /// @param pairHash The canonical keccak256 hash of the sorted token pair. + /// @return The sentinel-encoded pair fee (0 = not set). + function pairFees(bytes32 pairHash) external view returns (uint24); + + /// @notice Returns the number of breakpoints in the baseline curve. + /// @return The count of breakpoints. + function baselineCurveLength() external view returns (uint256); + + /// @notice Returns the breakpoint at the given index. + /// @param index The zero-based index into the curve array. + /// @return lpFeeFloor The minimum LP fee for this breakpoint. + /// @return protocolFee The protocol fee applied at this breakpoint. + function baselineCurve(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 protocolFee); + + // --- Pure Classification --- + + /// @notice Returns true if the hook has any RETURNS_DELTA flag set (bits 0-3). + /// @dev Pure function of the hook address — no storage reads, no external calls. + /// @param hook The hook address to check. + /// @return True if the hook performs custom accounting. + function isCustomAccounting(address hook) external pure returns (bool); + + // --- Fee Computation --- + + /// @notice Computes the protocol fee for a pool. + /// @dev Three paths: + /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee or baseline curve. + /// 2. Dynamic fee NativeMath: requires governance familyId (Slot0.lpFee is unreliable). + /// 3. CustomAccounting (return-delta flags set): requires governance familyId. + /// Paths 2 and 3 fall through to defaultFee if unclassified. + /// Callable by anyone (no access control) for offchain tooling. + /// @param key The pool key to compute the fee for. + /// @return fee The computed protocol fee (two 12-bit directional components packed). + function computeFee(PoolKey calldata key) external view returns (uint24 fee); + + // --- Admin (onlyOwner) --- + + /// @notice Sets the fee setter address. Only callable by owner. + /// @param newFeeSetter The new fee setter address. + function setFeeSetter(address newFeeSetter) external; + + // --- Classification (onlyFeeSetter) --- + + /// @notice Assign a hook to a governance-defined family. + /// @dev familyId 0 unclassifies the hook. Overwrites any existing classification. + /// @param hook The hook address to classify. + /// @param familyId The family ID to assign (0 = unclassify). + function setHookFamily(address hook, uint8 familyId) external; + + // --- Default Fee (onlyFeeSetter) --- + + /// @notice Sets the fallback fee for all classified pools (including unclassified hooks). + /// @dev Setting 0 sets an explicit zero fee. Use clearDefaultFee to remove entirely. + /// @param feeValue The protocol fee to set. Must pass isValidProtocolFee if non-zero. + function setDefaultFee(uint24 feeValue) external; + + /// @notice Removes the default fee, so unclassified pools return 0. + function clearDefaultFee() external; + + // --- Curve Configuration (onlyFeeSetter) --- + + /// @notice Replaces the entire baseline curve with new breakpoints. + /// @dev Breakpoints must be in strictly ascending order of lpFeeFloor. At least one + /// required. Each breakpoint's protocolFee must pass isValidProtocolFee. + /// @param breakpoints The new curve breakpoints, sorted ascending by lpFeeFloor. + function setBaselineCurve(CurveBreakpoint[] calldata breakpoints) external; + + // --- Family Defaults & Multipliers (onlyFeeSetter) --- + + /// @notice Sets the default protocol fee for a given family ID. + /// @dev familyId must be > 0. Setting 0 sets explicit zero. Use clearFamilyDefault to + /// remove entirely. + /// @param familyId The family to configure. + /// @param feeValue The default fee. Must pass isValidProtocolFee if non-zero. + function setFamilyDefault(uint8 familyId, uint24 feeValue) external; + + /// @notice Removes the default fee for a family, falling through in the waterfall. + /// @param familyId The family to clear. + function clearFamilyDefault(uint8 familyId) external; + + /// @notice Sets a multiplier for a family, applied to pairFees. + /// @dev familyId must be > 0. multiplierBps in basis points (10000 = 1x). + /// The scaled fee is clamped so each 12-bit component <= MAX_PROTOCOL_FEE. + /// @param familyId The family to configure. + /// @param multiplierBps The multiplier in basis points. + function setFamilyMultiplier(uint8 familyId, uint16 multiplierBps) external; + + /// @notice Removes the multiplier for a family. + /// @param familyId The family to clear. + function clearFamilyMultiplier(uint8 familyId) external; + + // --- Pair Fees (onlyFeeSetter) --- + + /// @notice Sets the pair fee for a token pair. + /// @dev StaticNativeMath pools use this directly (overrides baseline curve). Classified + /// pools scale it by familyMultiplierBps. Setting 0 sets explicit zero. + /// Use clearPairFee to remove entirely. + /// @param currency0 The lower currency of the pair (must be < currency1). + /// @param currency1 The higher currency of the pair. + /// @param feeValue The pair fee. Must pass isValidProtocolFee if non-zero. + function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) external; + + /// @notice Removes the pair fee, falling through to the baseline curve. + /// @param currency0 The lower currency of the pair (must be < currency1). + /// @param currency1 The higher currency of the pair. + function clearPairFee(Currency currency0, Currency currency1) external; +} diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol new file mode 100644 index 00000000..5989292e --- /dev/null +++ b/test/V4FeeAdapter.fork.t.sol @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {Deployers} from "../lib/v4-core/test/utils/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; +import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; +import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; + +import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; +import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; +import {CurveBreakpoint} from "../src/interfaces/IV4FeePolicy.sol"; + +/// @notice Integration tests using a real v4 PoolManager (deployed locally via Deployers). +/// Verifies protocol fee accrual from real swaps, collection to TokenJar, and the full +/// adapter + policy waterfall against live pool state. +contract V4FeeAdapterForkTest is Deployers { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + V4FeeAdapter adapter; + V4FeePolicy policy; + address tokenJar; + + address owner; + address feeSetter; + + PoolKey pool500; // 5 bps LP fee + PoolKey pool3000; // 30 bps LP fee + PoolKey pool10000; // 100 bps LP fee + + uint24 constant PROTO_FEE_100 = (100 << 12) | 100; + uint24 constant PROTO_FEE_200 = (200 << 12) | 200; + uint24 constant PROTO_FEE_300 = (300 << 12) | 300; + uint24 constant PROTO_FEE_500 = (500 << 12) | 500; + + function setUp() public { + owner = address(this); + feeSetter = makeAddr("feeSetter"); + + // Deploy real v4 PoolManager + routers + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + // Use a plain address as the fee destination (avoids TokenJar pragma conflict) + tokenJar = makeAddr("tokenJar"); + + // Deploy adapter + policy + policy = new V4FeePolicy(manager); + adapter = new V4FeeAdapter(manager, tokenJar); + adapter.setPolicy(policy); + adapter.setFeeSetter(feeSetter); + policy.setFeeSetter(feeSetter); + + // Register adapter as the protocolFeeController on the real PoolManager + manager.setProtocolFeeController(address(adapter)); + + // Initialize pools with liquidity at different fee tiers. + // Use explicit tick spacings and aligned tick ranges for each. + (pool3000,) = initPool(currency0, currency1, IHooks(address(0)), 3000, 60, SQRT_PRICE_1_1); + (pool500,) = initPool(currency0, currency1, IHooks(address(0)), 500, 10, SQRT_PRICE_1_1); + (pool10000,) = initPool(currency0, currency1, IHooks(address(0)), 10_000, 200, SQRT_PRICE_1_1); + + // Add liquidity with tick ranges aligned to each pool's tick spacing + modifyLiquidityRouter.modifyLiquidity( + pool3000, + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + modifyLiquidityRouter.modifyLiquidity( + pool500, + ModifyLiquidityParams({tickLower: -100, tickUpper: 100, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + modifyLiquidityRouter.modifyLiquidity( + pool10000, + ModifyLiquidityParams({tickLower: -200, tickUpper: 200, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + } + + // ============ End-to-End: Set Fee -> Swap -> Accrue -> Collect ============ + + function test_e2e_setFee_swap_collect() public { + // Configure baseline curve + CurveBreakpoint[] memory curve = new CurveBreakpoint[](3); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + curve[1] = CurveBreakpoint({lpFeeFloor: 1000, protocolFee: PROTO_FEE_200}); + curve[2] = CurveBreakpoint({lpFeeFloor: 5000, protocolFee: PROTO_FEE_500}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + + // Trigger fee update on the 3000 bps pool + adapter.triggerFeeUpdate(pool3000); + vm.snapshotGasLastCall("fork: triggerFeeUpdate single pool"); + + // Verify protocol fee was set on the PoolManager + (,, uint24 protocolFee,) = manager.getSlot0(pool3000.toId()); + assertEq(protocolFee, PROTO_FEE_200); + + // Execute a swap (oneForZero, exact input) + int256 swapAmount = -1e18; + SwapParams memory params = SwapParams({ + zeroForOne: false, amountSpecified: swapAmount, sqrtPriceLimitX96: MAX_PRICE_LIMIT + }); + BalanceDelta delta = + swapRouter.swap(pool3000, params, PoolSwapTest.TestSettings(false, false), ZERO_BYTES); + + // Protocol fees should have accrued on currency1 (the input) + uint256 expectedFee = + uint256(uint128(-delta.amount1())) * 200 / ProtocolFeeLibrary.PIPS_DENOMINATOR; + uint256 accrued = manager.protocolFeesAccrued(currency1); + assertEq(accrued, expectedFee); + assertTrue(accrued > 0, "No fees accrued"); + + // Collect to TokenJar + IV4FeeAdapter.CollectParams[] memory collectParams = new IV4FeeAdapter.CollectParams[](1); + collectParams[0] = IV4FeeAdapter.CollectParams({currency: currency1, amount: 0}); + adapter.collect(collectParams); + vm.snapshotGasLastCall("fork: collect single currency"); + + // Verify fees landed in TokenJar + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), accrued); + + // Verify accrued is now 0 + assertEq(manager.protocolFeesAccrued(currency1), 0); + } + + // ============ Baseline Curve: Different Tiers Get Different Fees ============ + + function test_baselineCurve_differentPoolsDifferentFees() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](3); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + curve[1] = CurveBreakpoint({lpFeeFloor: 1000, protocolFee: PROTO_FEE_300}); + curve[2] = CurveBreakpoint({lpFeeFloor: 5000, protocolFee: PROTO_FEE_500}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + + // Trigger all pools + PoolKey[] memory keys = new PoolKey[](3); + keys[0] = pool500; + keys[1] = pool3000; + keys[2] = pool10000; + adapter.batchTriggerFeeUpdate(keys); + vm.snapshotGasLastCall("fork: batchTriggerFeeUpdate 3 pools"); + + // 500 bps pool -> floor 0 matches -> PROTO_FEE_100 + (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); + assertEq(fee500, PROTO_FEE_100); + + // 3000 bps pool -> floor 1000 matches -> PROTO_FEE_300 + (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); + assertEq(fee3000, PROTO_FEE_300); + + // 10000 bps pool -> floor 5000 matches -> PROTO_FEE_500 + (,, uint24 fee10000,) = manager.getSlot0(pool10000.toId()); + assertEq(fee10000, PROTO_FEE_500); + } + + // ============ Pool Override Bypasses Policy ============ + + function test_poolOverride_bypassesBaselineCurve() public { + // Set baseline curve + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + vm.startPrank(feeSetter); + policy.setBaselineCurve(curve); + + // Override one pool to PROTO_FEE_500 + adapter.setPoolOverride(pool3000.toId(), PROTO_FEE_500); + vm.stopPrank(); + + // Trigger both + adapter.triggerFeeUpdate(pool3000); + adapter.triggerFeeUpdate(pool500); + + // pool3000 gets the override + (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); + assertEq(fee3000, PROTO_FEE_500); + + // pool500 gets the baseline + (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); + assertEq(fee500, PROTO_FEE_100); + } + + // ============ Pair Fee Overrides Curve ============ + + function test_pairFee_overridesBaselineCurve() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + vm.startPrank(feeSetter); + policy.setBaselineCurve(curve); + policy.setPairFee(currency0, currency1, PROTO_FEE_300); + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + + // Pair fee takes precedence over baseline + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, PROTO_FEE_300); + } + + // ============ Fees Accrue From Multiple Swaps ============ + + function test_feesAccrueFromMultipleSwaps() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + + adapter.triggerFeeUpdate(pool3000); + + // Execute 3 swaps in both directions + for (uint256 i; i < 3; ++i) { + swap(pool3000, true, -0.1e18, ZERO_BYTES); + swap(pool3000, false, -0.1e18, ZERO_BYTES); + } + + // Both currencies should have accrued fees + uint256 accrued0 = manager.protocolFeesAccrued(currency0); + uint256 accrued1 = manager.protocolFeesAccrued(currency1); + assertTrue(accrued0 > 0, "No fees accrued on currency0"); + assertTrue(accrued1 > 0, "No fees accrued on currency1"); + + // Collect both + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](2); + params[0] = IV4FeeAdapter.CollectParams({currency: currency0, amount: 0}); + params[1] = IV4FeeAdapter.CollectParams({currency: currency1, amount: 0}); + adapter.collect(params); + vm.snapshotGasLastCall("fork: collect 2 currencies"); + + assertEq(MockERC20(Currency.unwrap(currency0)).balanceOf(tokenJar), accrued0); + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), accrued1); + } + + // ============ Fee Update After Curve Change ============ + + function test_curveChange_requiresRetrigger() public { + // Set initial curve + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); + assertEq(feeBefore, PROTO_FEE_100); + + // Change curve + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_500}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + + // Pool still has old fee until retriggered + (,, uint24 feeStale,) = manager.getSlot0(pool3000.toId()); + assertEq(feeStale, PROTO_FEE_100); + + // Retrigger picks up new curve + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeAfter,) = manager.getSlot0(pool3000.toId()); + assertEq(feeAfter, PROTO_FEE_500); + } + + // ============ Policy Swap ============ + + function test_policySwap_newPolicyTakesEffect() public { + // Set up initial policy with fees + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); + assertEq(feeBefore, PROTO_FEE_300); + + // Deploy new policy with no curve (everything returns 0) + V4FeePolicy newPolicy = new V4FeePolicy(manager); + adapter.setPolicy(newPolicy); + + // Retrigger + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeAfter,) = manager.getSlot0(pool3000.toId()); + assertEq(feeAfter, 0); + } + + // ============ Explicit Zero Override Prevents Fee Accrual ============ + + function test_explicitZeroOverride_preventsFeeAccrual() public { + // Set baseline curve with real fees + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + vm.startPrank(feeSetter); + policy.setBaselineCurve(curve); + + // Override pool to explicit zero + adapter.setPoolOverride(pool3000.toId(), 0); + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + + // Pool should have zero protocol fee + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, 0); + + // Swap should not accrue any protocol fees + swap(pool3000, true, -1e18, ZERO_BYTES); + assertEq(manager.protocolFeesAccrued(currency0), 0); + assertEq(manager.protocolFeesAccrued(currency1), 0); + } + + // ============ Clear Override Restores Policy Behavior ============ + + function test_clearOverride_restoresPolicy() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + vm.startPrank(feeSetter); + policy.setBaselineCurve(curve); + adapter.setPoolOverride(pool3000.toId(), 0); // explicit zero + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeZero,) = manager.getSlot0(pool3000.toId()); + assertEq(feeZero, 0); + + // Clear override + vm.prank(feeSetter); + adapter.clearPoolOverride(pool3000.toId()); + + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeRestored,) = manager.getSlot0(pool3000.toId()); + assertEq(feeRestored, PROTO_FEE_300); + + // Swap now accrues fees + swap(pool3000, false, -1e18, ZERO_BYTES); + assertTrue(manager.protocolFeesAccrued(currency1) > 0, "Fees should accrue after clear"); + } + + // ============ Partial Collection ============ + + function test_partialCollection() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + adapter.triggerFeeUpdate(pool3000); + + // Swap to accrue fees + swap(pool3000, false, -10e18, ZERO_BYTES); + uint256 totalAccrued = manager.protocolFeesAccrued(currency1); + assertTrue(totalAccrued > 0); + + // Collect only half + uint256 halfAmount = totalAccrued / 2; + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: currency1, amount: halfAmount}); + adapter.collect(params); + + // Half still accrued, half in TokenJar + assertEq(manager.protocolFeesAccrued(currency1), totalAccrued - halfAmount); + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), halfAmount); + } + + // ============ Asymmetric Fees ============ + + function test_asymmetricFees() public { + // 500 pips 0->1, 100 pips 1->0 + uint24 asymmetric = (100 << 12) | 500; + vm.prank(feeSetter); + adapter.setPoolOverride(pool3000.toId(), asymmetric); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, asymmetric); + + // Swap zeroForOne (input is currency0, protocol fee = 500 pips on 0->1) + swap(pool3000, true, -1e18, ZERO_BYTES); + uint256 accrued0 = manager.protocolFeesAccrued(currency0); + + // Swap oneForZero (input is currency1, protocol fee = 100 pips on 1->0) + swap(pool3000, false, -1e18, ZERO_BYTES); + uint256 accrued1 = manager.protocolFeesAccrued(currency1); + + // Both should have fees, and currency0 should have more (higher fee direction) + assertTrue(accrued0 > 0, "0->1 fees should accrue"); + assertTrue(accrued1 > 0, "1->0 fees should accrue"); + assertTrue(accrued0 > accrued1, "0->1 fee should be higher"); + } +} diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol new file mode 100644 index 00000000..21a693e2 --- /dev/null +++ b/test/V4FeeAdapter.t.sol @@ -0,0 +1,1015 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; + +import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; +import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; +import {CurveBreakpoint} from "../src/interfaces/IV4FeePolicy.sol"; +import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; +import { + MockFeeClassifiedHook, + GriefingHook, + RevertingHook +} from "./mocks/MockFeeClassifiedHook.sol"; + +contract V4FeeAdapterTest is Test { + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + + MockV4PoolManager public poolManager; + V4FeeAdapter public adapter; + V4FeePolicy public policy; + + address public owner; + address public feeSetter; + address public tokenJar; + address public alice; + + MockERC20 public token0; + MockERC20 public token1; + + // Standard pool keys for testing + PoolKey public standardKey; // static fee, no hook + PoolKey public hookKey; // static fee, non-custom-accounting hook + PoolKey public dynamicKey; // dynamic fee, no hook + + // Protocol fee constants (symmetric 0->1 and 1->0) + uint24 constant FEE_100 = (100 << 12) | 100; // 100 pips both directions + uint24 constant FEE_200 = (200 << 12) | 200; + uint24 constant FEE_300 = (300 << 12) | 300; + uint24 constant FEE_500 = (500 << 12) | 500; + uint24 constant FEE_1000 = (1000 << 12) | 1000; // max both directions + + function setUp() public { + owner = makeAddr("owner"); + feeSetter = makeAddr("feeSetter"); + tokenJar = makeAddr("tokenJar"); + alice = makeAddr("alice"); + + // Deploy tokens (sorted by address) + token0 = new MockERC20("Token0", "T0", 18); + token1 = new MockERC20("Token1", "T1", 18); + if (address(token0) > address(token1)) (token0, token1) = (token1, token0); + + // Deploy mock pool manager + vm.prank(owner); + poolManager = new MockV4PoolManager(owner); + + // Deploy policy and adapter + vm.startPrank(owner); + policy = new V4FeePolicy(IPoolManager(address(poolManager))); + adapter = new V4FeeAdapter(IPoolManager(address(poolManager)), tokenJar); + adapter.setPolicy(policy); + adapter.setFeeSetter(feeSetter); + policy.setFeeSetter(feeSetter); + + // Register adapter as protocolFeeController + poolManager.setProtocolFeeController(address(adapter)); + vm.stopPrank(); + + // Build standard pool keys + standardKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + // Hook at address with NO return-delta flags (bits 0-3 clear, bit 7 set = beforeSwap) + hookKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(uint160(1 << 7))) // beforeSwap only, no custom accounting + }); + + dynamicKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + // Initialize pools + poolManager.mockInitialize(standardKey); + poolManager.mockInitialize(hookKey); + poolManager.mockInitialize(dynamicKey); + } + + // ============ Helpers ============ + + function _buildCurve() internal pure returns (CurveBreakpoint[] memory) { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](4); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: FEE_100}); + curve[1] = CurveBreakpoint({lpFeeFloor: 500, protocolFee: FEE_200}); + curve[2] = CurveBreakpoint({lpFeeFloor: 3000, protocolFee: FEE_300}); + curve[3] = CurveBreakpoint({lpFeeFloor: 10_000, protocolFee: FEE_500}); + return curve; + } + + function _pairHash() internal view returns (bytes32) { + return keccak256( + abi.encodePacked( + Currency.unwrap(standardKey.currency0), Currency.unwrap(standardKey.currency1) + ) + ); + } + + /// @dev Deploy a mock hook at a specific address using CREATE2. + /// The lowest 14 bits of the address encode hook permissions. + function _deployHookAt(uint160 flags, uint8 selfReportFamily) internal returns (address) { + // Deploy mock at an address with the desired flags via vm.etch + address hookAddr = address(flags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(selfReportFamily); + vm.etch(hookAddr, address(impl).code); + // Overwrite the immutable familyId in the etched bytecode's storage + vm.store(hookAddr, bytes32(0), bytes32(uint256(selfReportFamily))); + return hookAddr; + } + + // ============ Adapter: Construction ============ + + function test_adapter_constructor() public view { + assertEq(address(adapter.POOL_MANAGER()), address(poolManager)); + assertEq(adapter.TOKEN_JAR(), tokenJar); + assertEq(address(adapter.policy()), address(policy)); + assertEq(adapter.feeSetter(), feeSetter); + } + + // ============ Adapter: Admin ============ + + function test_setPolicy_success() public { + V4FeePolicy newPolicy = new V4FeePolicy(IPoolManager(address(poolManager))); + vm.expectEmit(true, true, false, false, address(adapter)); + emit IV4FeeAdapter.PolicyUpdated(address(policy), address(newPolicy)); + vm.prank(owner); + adapter.setPolicy(newPolicy); + assertEq(address(adapter.policy()), address(newPolicy)); + } + + function test_setPolicy_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + adapter.setPolicy(IV4FeePolicy(address(0))); + } + + function test_setPolicy_zeroDisablesPolicy() public { + vm.prank(owner); + adapter.setPolicy(IV4FeePolicy(address(0))); + assertEq(adapter.getFee(standardKey), 0); + } + + function test_setFeeSetter_adapter() public { + vm.expectEmit(true, true, false, false, address(adapter)); + emit IV4FeeAdapter.FeeSetterUpdated(feeSetter, alice); + vm.prank(owner); + adapter.setFeeSetter(alice); + assertEq(adapter.feeSetter(), alice); + } + + function test_setFeeSetter_adapter_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + adapter.setFeeSetter(alice); + } + + // ============ Adapter: Pool Overrides ============ + + function test_setPoolOverride_success() public { + PoolId id = standardKey.toId(); + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, FEE_500); + vm.prank(feeSetter); + adapter.setPoolOverride(id, FEE_500); + vm.snapshotGasLastCall("adapter.setPoolOverride"); + assertEq(adapter.getFee(standardKey), FEE_500); + } + + function test_setPoolOverride_zeroSetsExplicitZero() public { + PoolId id = standardKey.toId(); + + // Configure policy to return FEE_300 + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + assertEq(adapter.getFee(standardKey), FEE_300); + + // Set pool override to explicit zero -should NOT fall through to policy + vm.prank(feeSetter); + adapter.setPoolOverride(id, 0); + + // Raw storage holds sentinel (explicit zero), getFee decodes to 0 + assertEq(adapter.poolOverrides(id), type(uint24).max); + assertEq(adapter.getFee(standardKey), 0); + } + + function test_clearPoolOverride_fallsThroughToPolicy() public { + PoolId id = standardKey.toId(); + + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + // Set override then clear it + vm.startPrank(feeSetter); + adapter.setPoolOverride(id, FEE_500); + assertEq(adapter.getFee(standardKey), FEE_500); + + adapter.clearPoolOverride(id); + vm.stopPrank(); + + // Raw storage is 0 (not set), falls through to policy + assertEq(adapter.poolOverrides(id), 0); + assertEq(adapter.getFee(standardKey), FEE_300); + } + + function test_clearPoolOverride_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeeAdapter.Unauthorized.selector); + adapter.clearPoolOverride(standardKey.toId()); + } + + function test_setPoolOverride_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeeAdapter.Unauthorized.selector); + adapter.setPoolOverride(standardKey.toId(), FEE_100); + } + + function test_setPoolOverride_revertsInvalidFee() public { + // Fee with 12-bit component > 1000 + uint24 badFee = (1001 << 12) | 500; + vm.prank(feeSetter); + vm.expectRevert(IV4FeeAdapter.InvalidFeeValue.selector); + adapter.setPoolOverride(standardKey.toId(), badFee); + } + + function test_poolOverride_takesPriorityOverPolicy() public { + // Configure policy to return FEE_300 via baseline curve + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + // Set pool override to FEE_500 + adapter.setPoolOverride(standardKey.toId(), FEE_500); + vm.stopPrank(); + + assertEq(adapter.getFee(standardKey), FEE_500); + vm.snapshotGasLastCall("adapter.getFee - pool override hit"); + } + + // ============ Adapter: Fee Triggering ============ + + function test_triggerFeeUpdate_success() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + vm.expectEmit(true, true, false, true, address(adapter)); + emit IV4FeeAdapter.FeeUpdateTriggered(alice, standardKey.toId(), FEE_300); + vm.prank(alice); + adapter.triggerFeeUpdate(standardKey); + vm.snapshotGasLastCall("adapter.triggerFeeUpdate - single pool"); + + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + } + + function test_triggerFeeUpdate_skipsUninitializedPool() public { + PoolKey memory uninitKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 500, + tickSpacing: 10, + hooks: IHooks(address(0)) + }); + // Don't initialize -should not revert + adapter.triggerFeeUpdate(uninitKey); + assertEq(poolManager.getProtocolFee(uninitKey.toId()), 0); + } + + function testFuzz_triggerFeeUpdate_permissionless(address caller) public { + vm.assume(caller != address(0)); + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + vm.prank(caller); + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + } + + function test_batchTriggerFeeUpdate_success() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + PoolKey[] memory keys = new PoolKey[](2); + keys[0] = standardKey; + keys[1] = hookKey; + + adapter.batchTriggerFeeUpdate(keys); + vm.snapshotGasLastCall("adapter.batchTriggerFeeUpdate - two pools"); + + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + assertEq(poolManager.getProtocolFee(hookKey.toId()), FEE_300); + } + + // ============ Adapter: Collection ============ + + function test_collect_success() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 1000e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: amount}); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.FeesCollected(c, amount); + adapter.collect(params); + vm.snapshotGasLastCall("adapter.collect - single currency"); + + assertEq(token0.balanceOf(tokenJar), amount); + } + + function test_collect_zeroCollectsAll() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 500e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: 0}); + + adapter.collect(params); + assertEq(token0.balanceOf(tokenJar), amount); + } + + // ============ Policy: Construction ============ + + function test_policy_constructor() public view { + assertEq(address(policy.POOL_MANAGER()), address(poolManager)); + assertEq(policy.feeSetter(), feeSetter); + assertEq(policy.CUSTOM_ACCOUNTING_MASK(), 0xF); + } + + // ============ Policy: Admin ============ + + function test_setFeeSetter_policy() public { + vm.expectEmit(true, true, false, false, address(policy)); + emit IV4FeePolicy.FeeSetterUpdated(feeSetter, alice); + vm.prank(owner); + policy.setFeeSetter(alice); + assertEq(policy.feeSetter(), alice); + } + + function test_setFeeSetter_policy_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + policy.setFeeSetter(alice); + } + + // ============ Policy: isCustomAccounting ============ + + function test_isCustomAccounting_noHook() public view { + assertFalse(policy.isCustomAccounting(address(0))); + } + + function test_isCustomAccounting_noDeltaFlags() public view { + // Address with only bit 7 set (beforeSwap) -no custom accounting + assertFalse(policy.isCustomAccounting(address(uint160(1 << 7)))); + } + + function test_isCustomAccounting_beforeSwapReturnsDelta() public view { + // Bit 3 = BEFORE_SWAP_RETURNS_DELTA + assertTrue(policy.isCustomAccounting(address(uint160(1 << 3)))); + } + + function test_isCustomAccounting_afterSwapReturnsDelta() public view { + // Bit 2 = AFTER_SWAP_RETURNS_DELTA + assertTrue(policy.isCustomAccounting(address(uint160(1 << 2)))); + } + + function test_isCustomAccounting_allDeltaFlags() public view { + // Bits 0-3 all set + assertTrue(policy.isCustomAccounting(address(uint160(0xF)))); + } + + function testFuzz_isCustomAccounting(uint160 addr) public view { + bool expected = addr & 0xF != 0; + assertEq(policy.isCustomAccounting(address(addr)), expected); + } + + // ============ Policy: Baseline Curve ============ + + function test_setBaselineCurve_success() public { + CurveBreakpoint[] memory curve = _buildCurve(); + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.BaselineCurveUpdated(4); + vm.prank(feeSetter); + policy.setBaselineCurve(curve); + + vm.snapshotGasLastCall("policy.setBaselineCurve - four breakpoints"); + assertEq(policy.baselineCurveLength(), 4); + (uint24 floor, uint24 fee) = policy.baselineCurve(2); + assertEq(floor, 3000); + assertEq(fee, FEE_300); + } + + function test_setBaselineCurve_revertsEmpty() public { + CurveBreakpoint[] memory empty = new CurveBreakpoint[](0); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.EmptyCurve.selector); + policy.setBaselineCurve(empty); + } + + function test_setBaselineCurve_revertsNotAscending() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](2); + curve[0] = CurveBreakpoint({lpFeeFloor: 3000, protocolFee: FEE_300}); + curve[1] = CurveBreakpoint({lpFeeFloor: 500, protocolFee: FEE_200}); // not ascending + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.CurveNotAscending.selector); + policy.setBaselineCurve(curve); + } + + function test_setBaselineCurve_revertsInvalidFee() public { + CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); + curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: (1001 << 12) | 1001}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setBaselineCurve(curve); + } + + function test_setBaselineCurve_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setBaselineCurve(_buildCurve()); + } + + function test_setBaselineCurve_replacesExisting() public { + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + assertEq(policy.baselineCurveLength(), 4); + + CurveBreakpoint[] memory newCurve = new CurveBreakpoint[](1); + newCurve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: FEE_100}); + policy.setBaselineCurve(newCurve); + assertEq(policy.baselineCurveLength(), 1); + vm.stopPrank(); + } + + // ============ Policy: computeFee - static native math path ============ + + function test_computeFee_staticNativeMath_baselineCurve() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + // key.fee = 3000 -> should match the 3000 breakpoint -> FEE_300 + assertEq(policy.computeFee(standardKey), FEE_300); + vm.snapshotGasLastCall("policy.computeFee - static native math baseline curve"); + } + + function test_computeFee_staticNativeMath_baselineCurve_lowFee() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + PoolKey memory lowFeeKey = standardKey; + lowFeeKey.fee = 100; + poolManager.mockInitialize(lowFeeKey); + + // key.fee = 100, floor 0 matches -> FEE_100 + assertEq(policy.computeFee(lowFeeKey), FEE_100); + } + + function test_computeFee_staticNativeMath_pairFeeOverridesCurve() public { + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); + vm.stopPrank(); + + // Pair fee should override baseline curve + assertEq(policy.computeFee(standardKey), FEE_500); + vm.snapshotGasLastCall("policy.computeFee - static native math pair fee"); + } + + function test_computeFee_staticNativeMath_emptyCurveReturnsZero() public view { + assertEq(policy.computeFee(standardKey), 0); + } + + function test_computeFee_staticNativeMath_hookWithoutDeltaFlags() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + // hookKey has address with bit 7 set but bits 0-3 clear -> StaticNativeMath path + assertEq(policy.computeFee(hookKey), FEE_300); + } + + // ============ Policy: computeFee - classified path ============ + + function test_computeFee_classified_familyDefault() public { + // Create a pool with a custom-accounting hook (bit 2 = afterSwapReturnsDelta) + address customHook = address(uint160((1 << 7) | (1 << 2))); // beforeSwap + + // afterSwapReturnsDelta + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_200); + vm.snapshotGasLastCall("policy.computeFee - classified family default"); + } + + function test_computeFee_classified_pairFeeTimesMultiplier() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairFee(customKey.currency0, customKey.currency1, FEE_200); + policy.setFamilyMultiplier(1, 5000); // 50% + vm.stopPrank(); + + // FEE_200 = 200|200, multiplied by 50% = 100|100 = FEE_100 + assertEq(policy.computeFee(customKey), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified pairFee * multiplier"); + } + + function test_computeFee_classified_multiplierClamps() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairFee(customKey.currency0, customKey.currency1, FEE_1000); + policy.setFamilyMultiplier(1, 20_000); // 2x -> would be 2000, clamped to 1000 + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_1000); + } + + function test_computeFee_classified_unclassifiedFallsToDefault() public { + address customHook = address(uint160(1 << 2)); // custom accounting, no family set + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + assertEq(policy.computeFee(customKey), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified unclassified -> defaultFee"); + } + + function test_computeFee_classified_unclassifiedNoDefaultReturnsZero() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + assertEq(policy.computeFee(customKey), 0); + } + + function test_computeFee_dynamicFee_requiresClassification() public { + // Dynamic fee pool with no hook -> classified path, hookFamilyId[address(0)] = 0 + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_withFamily() public { + // Dynamic fee pool at address(0) -familyId lookup for address(0) + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_300); + } + + // ============ Policy: Hook Self-Report ============ + + function test_computeFee_selfReport_usedWhenNoGovernanceOverride() public { + // Deploy a self-reporting hook at an address with custom accounting flags + uint160 flags = (1 << 7) | (1 << 2); // beforeSwap + afterSwapReturnsDelta + address hookAddr = address(flags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(3); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory selfReportKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(selfReportKey); + + // Set family 3's default fee + vm.prank(feeSetter); + policy.setFamilyDefault(3, FEE_200); + + // Hook self-reports family 3, policy should use it + assertEq(policy.computeFee(selfReportKey), FEE_200); + vm.snapshotGasLastCall("policy.computeFee - classified self-report"); + } + + function test_computeFee_selfReport_governanceOverrideWins() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(3); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.startPrank(feeSetter); + policy.setFamilyDefault(3, FEE_200); // self-report family + policy.setFamilyDefault(5, FEE_500); // governance family + policy.setHookFamily(hookAddr, 5); // governance override + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_500); + } + + function test_computeFee_selfReport_revertingHookFallsToDefault() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + RevertingHook impl = new RevertingHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // Hook reverts -> treated as unclassified -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_computeFee_selfReport_griefingHookDoesNotDOS() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + GriefingHook impl = new GriefingHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // Gas-capped call should fail gracefully -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified griefing hook -> defaultFee"); + } + + // ============ Policy: Configuration Functions ============ + + function test_setHookFamily_success() public { + address hook = address(uint160(1 << 2)); + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.HookFamilySet(hook, 1); + vm.prank(feeSetter); + policy.setHookFamily(hook, 1); + vm.snapshotGasLastCall("policy.setHookFamily"); + assertEq(policy.hookFamilyId(hook), 1); + } + + function test_setHookFamily_overwrite() public { + address hook = address(uint160(1 << 2)); + vm.startPrank(feeSetter); + policy.setHookFamily(hook, 1); + policy.setHookFamily(hook, 5); + vm.stopPrank(); + assertEq(policy.hookFamilyId(hook), 5); + } + + function test_setHookFamily_zeroUnclassifies() public { + address hook = address(uint160(1 << 2)); + vm.startPrank(feeSetter); + policy.setHookFamily(hook, 3); + policy.setHookFamily(hook, 0); + vm.stopPrank(); + assertEq(policy.hookFamilyId(hook), 0); + } + + function test_setHookFamily_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setHookFamily(address(1), 1); + } + + function test_setDefaultFee_success() public { + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(FEE_100); + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + } + + function test_setDefaultFee_revertsInvalidFee() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setDefaultFee((2000 << 12) | 2000); + } + + function test_setFamilyDefault_success() public { + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, FEE_300); + vm.prank(feeSetter); + policy.setFamilyDefault(1, FEE_300); + vm.snapshotGasLastCall("policy.setFamilyDefault"); + assertEq(policy.familyDefaults(1), FEE_300); + } + + function test_setFamilyDefault_revertsZeroFamily() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.setFamilyDefault(0, FEE_100); + } + + function test_setFamilyMultiplier_success() public { + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyMultiplierUpdated(2, 5000); + vm.prank(feeSetter); + policy.setFamilyMultiplier(2, 5000); + vm.snapshotGasLastCall("policy.setFamilyMultiplier"); + assertEq(policy.familyMultiplierBps(2), 5000); + } + + function test_setFamilyMultiplier_revertsZeroFamily() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.setFamilyMultiplier(0, 10_000); + } + + function test_setPairFee_success() public { + bytes32 ph = _pairHash(); + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.PairFeeUpdated(ph, FEE_200); + vm.prank(feeSetter); + policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_200); + vm.snapshotGasLastCall("policy.setPairFee"); + assertEq(policy.pairFees(ph), FEE_200); + } + + function test_setPairFee_revertsCurrenciesOutOfOrder() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); + policy.setPairFee(standardKey.currency1, standardKey.currency0, FEE_200); + } + + function test_setPairFee_revertsInvalidFee() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setPairFee(standardKey.currency0, standardKey.currency1, (1001 << 12) | 500); + } + + // ============ Policy: Sentinel Encoding ============ + + function test_sentinel_setZeroIsExplicitZero() public { + // setFamilyDefault(1, 0) stores sentinel -explicit zero fee, not "unset" + vm.startPrank(feeSetter); + policy.setFamilyDefault(1, FEE_200); + assertEq(policy.familyDefaults(1), FEE_200); + + policy.setFamilyDefault(1, 0); + assertEq(policy.familyDefaults(1), type(uint24).max); // sentinel in storage + vm.stopPrank(); + + // computeFee decodes sentinel to 0 -explicit zero, does not fall through + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setDefaultFee(FEE_500); // would be used if familyDefault were unset + vm.stopPrank(); + + // Family 1 has explicit zero -> 0, NOT the defaultFee of FEE_500 + assertEq(policy.computeFee(customKey), 0); + } + + function test_sentinel_clearFallsThrough() public { + // clearFamilyDefault deletes storage -> falls through to defaultFee + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + policy.setDefaultFee(FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_200); // familyDefault wins + + vm.prank(feeSetter); + policy.clearFamilyDefault(1); + + assertEq(policy.familyDefaults(1), 0); // storage is 0, not sentinel + assertEq(policy.computeFee(customKey), FEE_500); // falls through to defaultFee + } + + // ============ Policy: Clear Functions ============ + + function test_clearDefaultFee() public { + vm.startPrank(feeSetter); + policy.setDefaultFee(FEE_200); + assertEq(policy.defaultFee(), FEE_200); + + policy.clearDefaultFee(); + assertEq(policy.defaultFee(), 0); // storage deleted, not sentinel + vm.stopPrank(); + } + + function test_clearFamilyMultiplier() public { + vm.startPrank(feeSetter); + policy.setFamilyMultiplier(1, 5000); + assertEq(policy.familyMultiplierBps(1), 5000); + + policy.clearFamilyMultiplier(1); + assertEq(policy.familyMultiplierBps(1), 0); + vm.stopPrank(); + } + + function test_clearPairFee_fallsThroughToCurve() public { + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(standardKey), FEE_500); // pair fee wins + + vm.prank(feeSetter); + policy.clearPairFee(standardKey.currency0, standardKey.currency1); + + assertEq(policy.pairFees(_pairHash()), 0); // storage deleted + assertEq(policy.computeFee(standardKey), FEE_300); // back to baseline curve + } + + function test_clearPairFee_revertsCurrenciesOutOfOrder() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); + policy.clearPairFee(standardKey.currency1, standardKey.currency0); + } + + // ============ Integration: Full Waterfall ============ + + function test_integration_fullWaterfall() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + policy.setPairFee(customKey.currency0, customKey.currency1, FEE_300); + policy.setFamilyMultiplier(1, 10_000); // 1x + vm.stopPrank(); + + // StandardKey -> StaticNativeMath -> pair fee overrides curve -> FEE_300 + assertEq(adapter.getFee(standardKey), FEE_300); + + // CustomKey -> Classified -> pair fee × multiplier -> FEE_300 × 1x = FEE_300 + assertEq(adapter.getFee(customKey), FEE_300); + + // Pool override beats everything + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), FEE_1000); + assertEq(adapter.getFee(standardKey), FEE_1000); + } + + function test_integration_triggerAndCollect() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 100e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + // Trigger fee update + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + + // Collect fees + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: 0}); + adapter.collect(params); + assertEq(token0.balanceOf(tokenJar), amount); + } + + // ============ Edge Cases ============ + + function test_edge_maxProtocolFee() public { + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), FEE_1000); + assertEq(adapter.getFee(standardKey), FEE_1000); + } + + function test_edge_asymmetricFee() public { + uint24 asymmetric = (500 << 12) | 200; // 500 pips 1->0, 200 pips 0->1 + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), asymmetric); + + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), asymmetric); + } + + function test_edge_policySwap() public { + vm.prank(feeSetter); + policy.setBaselineCurve(_buildCurve()); + + assertEq(adapter.getFee(standardKey), FEE_300); + + // Deploy new policy with different curve + vm.startPrank(owner); + V4FeePolicy newPolicy = new V4FeePolicy(IPoolManager(address(poolManager))); + newPolicy.setFeeSetter(feeSetter); + adapter.setPolicy(newPolicy); + vm.stopPrank(); + + // New policy has no curve -> 0 + assertEq(adapter.getFee(standardKey), 0); + } +} diff --git a/test/mocks/MockFeeClassifiedHook.sol b/test/mocks/MockFeeClassifiedHook.sol new file mode 100644 index 00000000..26ba3453 --- /dev/null +++ b/test/mocks/MockFeeClassifiedHook.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {IFeeClassifiedHook} from "../../src/interfaces/IFeeClassifiedHook.sol"; + +/// @notice Mock hook that self-reports its protocol fee family via IFeeClassifiedHook. +contract MockFeeClassifiedHook is IFeeClassifiedHook { + uint8 public immutable familyId; + + constructor(uint8 _familyId) { + familyId = _familyId; + } + + function protocolFeeFamily() external view returns (uint8) { + return familyId; + } +} + +/// @notice Mock hook that wastes all gas on protocolFeeFamily() to test griefing protection. +contract GriefingHook is IFeeClassifiedHook { + function protocolFeeFamily() external pure returns (uint8) { + while (true) {} // infinite loop — will consume all gas + return 1; // unreachable + } +} + +/// @notice Mock hook that reverts on protocolFeeFamily(). +contract RevertingHook { + function protocolFeeFamily() external pure returns (uint8) { + revert("not classified"); + } +} diff --git a/test/mocks/MockV4PoolManager.sol b/test/mocks/MockV4PoolManager.sol new file mode 100644 index 00000000..32c5e48a --- /dev/null +++ b/test/mocks/MockV4PoolManager.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {ProtocolFees} from "v4-core/ProtocolFees.sol"; +import {Extsload} from "v4-core/Extsload.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Pool} from "v4-core/libraries/Pool.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {Slot0} from "v4-core/types/Slot0.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {IExtsload} from "v4-core/interfaces/IExtsload.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; + +/// @title MockV4PoolManager +/// @notice A minimal mock of the V4 PoolManager that supports ProtocolFees, extsload, and +/// enough IPoolManager surface for the V4FeeAdapter to function. Pools are stored in a +/// mapping at the same storage slot as the real PoolManager (slot 6 via POOLS_SLOT) because +/// ProtocolFees uses the same internal _getPool pattern. +/// @dev This mock does NOT implement the full IPoolManager interface. Unimplemented functions +/// revert with NotSupported(). +contract MockV4PoolManager is ProtocolFees, Extsload { + using PoolIdLibrary for PoolKey; + + error NotSupported(); + + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + /// @dev The pools mapping. In the real PoolManager, this is at storage slot 6 + /// (StateLibrary.POOLS_SLOT). Our storage layout must match for extsload to work: + /// Owned.owner (slot 0), ProtocolFees.protocolFeesAccrued (slot 1), + /// ProtocolFees.protocolFeeController (slot 2), and then we need 3 padding slots + /// before _pools lands at slot 6. We use explicit padding variables. + uint256 private _pad3; + uint256 private _pad4; + uint256 private _pad5; + mapping(PoolId => Pool.State) internal _pools; + + constructor(address initialOwner) ProtocolFees(initialOwner) {} + + /// @notice Initialize a pool with the standard 1:1 price. + /// @param poolKey The pool key to initialize. + function mockInitialize(PoolKey memory poolKey) external { + Pool.State storage state = _pools[poolKey.toId()]; + state.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(SQRT_PRICE_1_1); + } + + /// @notice Initialize a pool with a specific LP fee set in Slot0. + /// @param poolKey The pool key to initialize. + /// @param lpFee The LP fee to set. + function mockInitializeWithLpFee(PoolKey memory poolKey, uint24 lpFee) external { + Pool.State storage state = _pools[poolKey.toId()]; + state.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(SQRT_PRICE_1_1).setLpFee(lpFee); + } + + /// @notice Read the protocol fee from a pool's Slot0. + /// @param id The pool ID to query. + /// @return The protocol fee currently set on the pool. + function getProtocolFee(PoolId id) external view returns (uint24) { + return _pools[id].slot0.protocolFee(); + } + + /// @notice Set accrued protocol fees for a currency (for testing collection). + /// @param currency The currency to set fees for. + /// @param amount The amount of fees accrued. + function setProtocolFeesAccrued(Currency currency, uint256 amount) external { + protocolFeesAccrued[currency] = amount; + } + + // ─── ProtocolFees overrides ─── + + function _isUnlocked() internal pure override returns (bool) { + return false; + } + + function _getPool(PoolId id) internal view override returns (Pool.State storage) { + return _pools[id]; + } + + // ─── IPoolManager stubs (required for casting but not used in tests) ─────── + + function unlock(bytes calldata) external pure returns (bytes memory) { + revert NotSupported(); + } + + function initialize(PoolKey memory, uint160) external pure returns (int24) { + revert NotSupported(); + } + + function modifyLiquidity(PoolKey memory, ModifyLiquidityParams memory, bytes calldata) + external + pure + returns (BalanceDelta, BalanceDelta) + { + revert NotSupported(); + } + + function swap(PoolKey memory, SwapParams memory, bytes calldata) + external + pure + returns (BalanceDelta) + { + revert NotSupported(); + } + + function donate(PoolKey memory, uint256, uint256, bytes calldata) + external + pure + returns (BalanceDelta) + { + revert NotSupported(); + } + + function sync(Currency) external pure { + revert NotSupported(); + } + + function take(Currency, address, uint256) external pure { + revert NotSupported(); + } + + function settle() external payable returns (uint256) { + revert NotSupported(); + } + + function settleFor(address) external payable returns (uint256) { + revert NotSupported(); + } + + function clear(Currency, uint256) external pure { + revert NotSupported(); + } + + function mint(address, uint256, uint256) external pure { + revert NotSupported(); + } + + function burn(address, uint256, uint256) external pure { + revert NotSupported(); + } + + function updateDynamicLPFee(PoolKey memory, uint24) external pure { + revert NotSupported(); + } +} From 001026f3c541005d1049741a54fe02fbe5d1297e Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 17 Apr 2026 04:13:03 -0400 Subject: [PATCH 02/26] feat: replace opaque familyId self-report with composable flag bitfield system Hook self-classification now uses a uint256 bitfield of OR'd behavioral flags (HookFeeFlags library) instead of a single uint8 familyId. Governance configures priority-ordered flag rules that map flag patterns to families, decoupling what a hook does from what fee it gets. Existing family-based fee resolution (defaults, multipliers, pair fees) is unchanged. --- src/feeAdapters/V4FeePolicy.sol | 75 ++++- src/interfaces/IFeeClassifiedHook.sol | 15 +- src/interfaces/IV4FeePolicy.sol | 50 ++- src/libraries/HookFeeFlags.sol | 29 ++ test/V4FeeAdapter.t.sol | 432 ++++++++++++++++++++++++-- test/mocks/MockFeeClassifiedHook.sol | 22 +- 6 files changed, 575 insertions(+), 48 deletions(-) create mode 100644 src/libraries/HookFeeFlags.sol diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index d8a9a0ca..d2301ecf 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -8,7 +8,7 @@ import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; -import {IV4FeePolicy, CurveBreakpoint} from "../interfaces/IV4FeePolicy.sol"; +import {IV4FeePolicy, CurveBreakpoint, FlagRule} from "../interfaces/IV4FeePolicy.sol"; import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @title V4FeePolicy @@ -19,8 +19,9 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// - Classified: custom accounting or dynamic fee → family multiplier × pair fee, family /// default, or global default fee. /// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). -/// Hooks can also self-report their family via IFeeClassifiedHook.protocolFeeFamily(). -/// Priority: governance override → hook self-report → defaultFee. +/// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(). +/// Governance-configured flag rules map flag patterns to families automatically. +/// Priority: governance override → flag-rule match on self-reported flags → defaultFee. /// @custom:security-contact security@uniswap.org contract V4FeePolicy is IV4FeePolicy, Owned { using LPFeeLibrary for uint24; @@ -61,6 +62,13 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// StaticNativeMath pools to map key.fee to a protocol fee. CurveBreakpoint[] internal _baselineCurve; + /// @dev Maximum number of flag rules to bound gas in _resolveFamily. + uint256 internal constant MAX_FLAG_RULES = 32; + + /// @dev Ordered flag rules for mapping self-reported hook flags to family IDs. + /// First matching rule wins. Set atomically via setFlagRules(). + FlagRule[] internal _flagRules; + /// @notice Restricts access to the fee setter address. modifier onlyFeeSetter() { if (msg.sender != feeSetter) revert Unauthorized(); @@ -94,7 +102,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } // Classified: custom accounting OR dynamic fee - // Priority: governance override → hook self-report → unclassified + // Priority: governance override → flag-rule match → unclassified uint8 family = _resolveFamily(hook); if (family != 0) { uint24 pairFee = pairFees[ph]; @@ -128,6 +136,23 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return (bp.lpFeeFloor, bp.protocolFee); } + // ─── Flag Rules Getters ─── + + /// @inheritdoc IV4FeePolicy + function flagRulesLength() external view returns (uint256) { + return _flagRules.length; + } + + /// @inheritdoc IV4FeePolicy + function flagRules(uint256 index) + external + view + returns (uint256 requiredFlags, uint8 familyId) + { + FlagRule storage rule = _flagRules[index]; + return (rule.requiredFlags, rule.familyId); + } + // ─── Admin ─── /// @inheritdoc IV4FeePolicy @@ -144,6 +169,27 @@ contract V4FeePolicy is IV4FeePolicy, Owned { emit HookFamilySet(hook, familyId); } + /// @inheritdoc IV4FeePolicy + function setFlagRules(FlagRule[] calldata rules) external onlyFeeSetter { + if (rules.length > MAX_FLAG_RULES) revert TooManyFlagRules(); + + delete _flagRules; + + for (uint256 i; i < rules.length; ++i) { + FlagRule calldata rule = rules[i]; + if (rule.requiredFlags == 0 || rule.familyId == 0) revert InvalidFlagRule(); + _flagRules.push(rule); + } + + emit FlagRulesUpdated(rules.length); + } + + /// @inheritdoc IV4FeePolicy + function clearFlagRules() external onlyFeeSetter { + delete _flagRules; + emit FlagRulesUpdated(0); + } + /// @inheritdoc IV4FeePolicy function setDefaultFee(uint24 feeValue) external onlyFeeSetter { if (feeValue != 0) _validateFee(feeValue); @@ -238,8 +284,8 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @dev Resolves the family ID for a hook using a priority chain: /// 1. Governance override (hookFamilyId[hook]) — always wins if non-zero. - /// 2. Hook self-report via IFeeClassifiedHook.protocolFeeFamily() — gas-capped - /// staticcall at SELF_REPORT_GAS_LIMIT. Trusted if call succeeds and returns non-zero. + /// 2. Flag-rule match: gas-capped staticcall to protocolFeeFlags(), then walk + /// _flagRules in order. First rule whose requiredFlags are all present wins. /// 3. Returns 0 (unclassified) if neither source provides a family. /// @param hook The hook contract address to resolve. /// @return The resolved family ID, or 0 if unclassified. @@ -247,13 +293,22 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint8 gov = hookFamilyId[hook]; if (gov != 0) return gov; - // Ask the hook if it self-reports a family (gas-capped to prevent griefing) + uint256 rulesLen = _flagRules.length; + if (rulesLen == 0) return 0; + (bool ok, bytes memory ret) = hook.staticcall{gas: SELF_REPORT_GAS_LIMIT}( - abi.encodeCall(IFeeClassifiedHook.protocolFeeFamily, ()) + abi.encodeCall(IFeeClassifiedHook.protocolFeeFlags, ()) ); if (ok && ret.length >= 32) { - uint8 reported = abi.decode(ret, (uint8)); - if (reported != 0) return reported; + uint256 flags = abi.decode(ret, (uint256)); + if (flags != 0) { + for (uint256 i; i < rulesLen; ++i) { + FlagRule storage rule = _flagRules[i]; + if (flags & rule.requiredFlags == rule.requiredFlags) { + return rule.familyId; + } + } + } } return 0; diff --git a/src/interfaces/IFeeClassifiedHook.sol b/src/interfaces/IFeeClassifiedHook.sol index 3aaeac7e..58501b42 100644 --- a/src/interfaces/IFeeClassifiedHook.sol +++ b/src/interfaces/IFeeClassifiedHook.sol @@ -2,15 +2,16 @@ pragma solidity ^0.8.26; /// @title IFeeClassifiedHook -/// @notice Optional interface for v4 hooks to self-report their protocol fee family. -/// @dev Hooks that implement this allow the V4FeePolicy to automatically classify them -/// without requiring a governance transaction. The policy calls protocolFeeFamily() with -/// a gas cap; if the call succeeds and returns a valid familyId, it is trusted. +/// @notice Optional interface for v4 hooks to self-report behavioral flags for +/// protocol fee classification. +/// @dev Hooks return a uint256 bitfield of OR'd flags from HookFeeFlags. The +/// V4FeePolicy matches these flags against governance-configured rules to derive +/// a family ID. Gas-capped staticcall prevents griefing. /// Governance can always override via setHookFamily(). /// @custom:security-contact security@uniswap.org interface IFeeClassifiedHook { - /// @notice Returns the hook's self-reported fee family ID. + /// @notice Returns the hook's self-reported behavioral flags. /// @dev Return 0 to indicate no self-classification (falls through to defaultFee). - /// Family IDs (1-255) are governance-defined — see documentation for semantics. - function protocolFeeFamily() external view returns (uint8); + /// Flags are OR'd constants from HookFeeFlags — see that library for the vocabulary. + function protocolFeeFlags() external view returns (uint256); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index e289ded6..b8162dde 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -16,13 +16,26 @@ struct CurveBreakpoint { uint24 protocolFee; } +/// @dev A flag-to-family mapping rule. The policy walks rules in order; the first rule +/// whose requiredFlags are all present in the hook's self-reported flags wins. +struct FlagRule { + /// @dev Bitmask of flags that must ALL be set in the hook's protocolFeeFlags() return + /// value for this rule to match. Use OR'd constants from HookFeeFlags. + uint256 requiredFlags; + /// @dev The family ID assigned when this rule matches. Must be > 0. + uint8 familyId; +} + /// @title IV4FeePolicy /// @notice Interface for the V4 fee policy contract that computes protocol fees based on /// automated hook classification and governance-configured parameters. /// @dev Hook family IDs are governance-assigned uint8 values (1-255). 0 = unclassified. /// Family IDs have no hardcoded semantic meaning — labels live in offchain documentation. +/// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); +/// governance-configured flag rules map flag patterns to families automatically. /// Static NativeMath pools bypass classification and use the baseline curve directly. -/// Custom-accounting hooks and dynamic fee pools require a governance-assigned familyId. +/// Custom-accounting hooks and dynamic fee pools require classification (governance +/// override, flag rule match, or defaultFee fallback). /// @custom:security-contact security@uniswap.org interface IV4FeePolicy { // --- Errors --- @@ -45,6 +58,12 @@ interface IV4FeePolicy { /// @notice Thrown when currency0 >= currency1 in setPairFee. error CurrenciesOutOfOrder(); + /// @notice Thrown when a flag rule has requiredFlags == 0 or familyId == 0. + error InvalidFlagRule(); + + /// @notice Thrown when flag rules exceed the maximum allowed count. + error TooManyFlagRules(); + // --- Events --- /// @notice Emitted when the fee setter address is updated. @@ -80,6 +99,10 @@ interface IV4FeePolicy { /// @param feeValue The new default fee (0 = removed). event DefaultFeeUpdated(uint24 feeValue); + /// @notice Emitted when the flag rules array is replaced. + /// @param ruleCount The number of rules in the new array. + event FlagRulesUpdated(uint256 ruleCount); + // --- Constants --- /// @notice Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). @@ -142,6 +165,19 @@ interface IV4FeePolicy { view returns (uint24 lpFeeFloor, uint24 protocolFee); + /// @notice Returns the number of flag rules configured. + /// @return The count of flag rules. + function flagRulesLength() external view returns (uint256); + + /// @notice Returns the flag rule at the given index. + /// @param index The zero-based index into the rules array. + /// @return requiredFlags The flags that must all be present for a match. + /// @return familyId The family ID assigned on match. + function flagRules(uint256 index) + external + view + returns (uint256 requiredFlags, uint8 familyId); + // --- Pure Classification --- /// @notice Returns true if the hook has any RETURNS_DELTA flag set (bits 0-3). @@ -177,6 +213,18 @@ interface IV4FeePolicy { /// @param familyId The family ID to assign (0 = unclassify). function setHookFamily(address hook, uint8 familyId) external; + // --- Flag Rules (onlyFeeSetter) --- + + /// @notice Replaces the entire flag rules array atomically. + /// @dev Rules are checked in order; the first rule whose requiredFlags are all present + /// in the hook's self-reported flags wins. More specific patterns should come first. + /// Each rule must have requiredFlags != 0 and familyId > 0. Max 32 rules. + /// @param rules The new flag rules, ordered by match priority (first match wins). + function setFlagRules(FlagRule[] calldata rules) external; + + /// @notice Removes all flag rules. + function clearFlagRules() external; + // --- Default Fee (onlyFeeSetter) --- /// @notice Sets the fallback fee for all classified pools (including unclassified hooks). diff --git a/src/libraries/HookFeeFlags.sol b/src/libraries/HookFeeFlags.sol new file mode 100644 index 00000000..bdd6ffd2 --- /dev/null +++ b/src/libraries/HookFeeFlags.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +/// @title HookFeeFlags +/// @notice Well-known behavioral flags for hook fee classification. +/// @dev Hooks OR these flags together and return the result from protocolFeeFlags(). +/// The V4FeePolicy matches returned flags against governance-configured rules to +/// derive a family ID. Flags occupy a uint256; bits 0-11 are defined here with +/// room for future additions. +/// @custom:security-contact security@uniswap.org +library HookFeeFlags { + // --- Value extraction (bits 0-3) --- + uint256 internal constant TAKES_SWAP_SURPLUS = 1 << 0; + uint256 internal constant TAKES_LP_SURPLUS = 1 << 1; + uint256 internal constant USES_DYNAMIC_FEE = 1 << 2; + uint256 internal constant REBALANCES_POOL = 1 << 3; + + // --- Strategy type (bits 4-7) --- + uint256 internal constant STABLE_PAIR = 1 << 4; + uint256 internal constant ORACLE_BASED = 1 << 5; + uint256 internal constant LIMIT_ORDER = 1 << 6; + uint256 internal constant AUCTION_BASED = 1 << 7; + + // --- Integration type (bits 8-11) --- + uint256 internal constant LENDING_INTEGRATION = 1 << 8; + uint256 internal constant YIELD_BEARING = 1 << 9; + uint256 internal constant CROSS_CHAIN = 1 << 10; + uint256 internal constant AGGREGATOR = 1 << 11; +} diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 21a693e2..e25d4076 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -13,7 +13,8 @@ import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; -import {CurveBreakpoint} from "../src/interfaces/IV4FeePolicy.sol"; +import {CurveBreakpoint, FlagRule} from "../src/interfaces/IV4FeePolicy.sol"; +import {HookFeeFlags} from "../src/libraries/HookFeeFlags.sol"; import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { MockFeeClassifiedHook, @@ -127,15 +128,13 @@ contract V4FeeAdapterTest is Test { ); } - /// @dev Deploy a mock hook at a specific address using CREATE2. + /// @dev Deploy a mock hook at a specific address using vm.etch. /// The lowest 14 bits of the address encode hook permissions. - function _deployHookAt(uint160 flags, uint8 selfReportFamily) internal returns (address) { - // Deploy mock at an address with the desired flags via vm.etch - address hookAddr = address(flags); - MockFeeClassifiedHook impl = new MockFeeClassifiedHook(selfReportFamily); + function _deployHookAt(uint160 addrFlags, uint256 feeFlags) internal returns (address) { + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); vm.etch(hookAddr, address(impl).code); - // Overwrite the immutable familyId in the etched bytecode's storage - vm.store(hookAddr, bytes32(0), bytes32(uint256(selfReportFamily))); + vm.store(hookAddr, bytes32(0), bytes32(feeFlags)); return hookAddr; } @@ -630,9 +629,10 @@ contract V4FeeAdapterTest is Test { function test_computeFee_selfReport_usedWhenNoGovernanceOverride() public { // Deploy a self-reporting hook at an address with custom accounting flags - uint160 flags = (1 << 7) | (1 << 2); // beforeSwap + afterSwapReturnsDelta - address hookAddr = address(flags); - MockFeeClassifiedHook impl = new MockFeeClassifiedHook(3); + uint160 addrFlags = (1 << 7) | (1 << 2); // beforeSwap + afterSwapReturnsDelta + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); vm.etch(hookAddr, address(impl).code); PoolKey memory selfReportKey = PoolKey({ @@ -644,19 +644,25 @@ contract V4FeeAdapterTest is Test { }); poolManager.mockInitialize(selfReportKey); - // Set family 3's default fee - vm.prank(feeSetter); + // Configure flag rule: TAKES_SWAP_SURPLUS -> family 3 + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); policy.setFamilyDefault(3, FEE_200); + vm.stopPrank(); - // Hook self-reports family 3, policy should use it + // Hook self-reports TAKES_SWAP_SURPLUS, rule maps to family 3 assertEq(policy.computeFee(selfReportKey), FEE_200); - vm.snapshotGasLastCall("policy.computeFee - classified self-report"); + vm.snapshotGasLastCall("policy.computeFee - classified flag-rule self-report"); } function test_computeFee_selfReport_governanceOverrideWins() public { - uint160 flags = (1 << 7) | (1 << 2); - address hookAddr = address(flags); - MockFeeClassifiedHook impl = new MockFeeClassifiedHook(3); + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); vm.etch(hookAddr, address(impl).code); PoolKey memory key = PoolKey({ @@ -668,8 +674,12 @@ contract V4FeeAdapterTest is Test { }); poolManager.mockInitialize(key); + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3}); + vm.startPrank(feeSetter); - policy.setFamilyDefault(3, FEE_200); // self-report family + policy.setFlagRules(rules); + policy.setFamilyDefault(3, FEE_200); // flag-rule family policy.setFamilyDefault(5, FEE_500); // governance family policy.setHookFamily(hookAddr, 5); // governance override vm.stopPrank(); @@ -1012,4 +1022,388 @@ contract V4FeeAdapterTest is Test { // New policy has no curve -> 0 assertEq(adapter.getFee(standardKey), 0); } + + // ============ Policy: Flag Rules Configuration ============ + + function test_setFlagRules_success() public { + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, + familyId: 3 + }); + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FlagRulesUpdated(2); + vm.prank(feeSetter); + policy.setFlagRules(rules); + vm.snapshotGasLastCall("policy.setFlagRules - two rules"); + + assertEq(policy.flagRulesLength(), 2); + (uint256 flags0, uint8 fam0) = policy.flagRules(0); + assertEq(flags0, HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS); + assertEq(fam0, 3); + (uint256 flags1, uint8 fam1) = policy.flagRules(1); + assertEq(flags1, HookFeeFlags.TAKES_SWAP_SURPLUS); + assertEq(fam1, 2); + } + + function test_setFlagRules_replacesExisting() public { + FlagRule[] memory rules1 = new FlagRule[](2); + rules1[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + rules1[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + FlagRule[] memory rules2 = new FlagRule[](1); + rules2[0] = FlagRule({requiredFlags: HookFeeFlags.ORACLE_BASED, familyId: 5}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules1); + assertEq(policy.flagRulesLength(), 2); + policy.setFlagRules(rules2); + assertEq(policy.flagRulesLength(), 1); + vm.stopPrank(); + + (uint256 flags, uint8 fam) = policy.flagRules(0); + assertEq(flags, HookFeeFlags.ORACLE_BASED); + assertEq(fam, 5); + } + + function test_setFlagRules_revertsUnauthorized() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsZeroRequiredFlags() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: 0, familyId: 1}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFlagRule.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsZeroFamilyId() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFlagRule.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsTooManyRules() public { + FlagRule[] memory rules = new FlagRule[](33); + for (uint256 i; i < 33; ++i) { + rules[i] = FlagRule({requiredFlags: 1 << i, familyId: uint8(i + 1)}); + } + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.TooManyFlagRules.selector); + policy.setFlagRules(rules); + } + + function test_clearFlagRules() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + assertEq(policy.flagRulesLength(), 1); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FlagRulesUpdated(0); + policy.clearFlagRules(); + assertEq(policy.flagRulesLength(), 0); + vm.stopPrank(); + } + + function test_clearFlagRules_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.clearFlagRules(); + } + + // ============ Policy: Flag-Based Classification ============ + + function test_flagRule_singleFlagMatch() public { + uint160 addrFlags = (1 << 7) | (1 << 2); // custom accounting + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_300); + vm.snapshotGasLastCall("policy.computeFee - flag-rule single flag match"); + } + + function test_flagRule_multiFlagMatch() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS | HookFeeFlags.STABLE_PAIR; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](2); + // More specific rule first: both flags required + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, + familyId: 3 + }); + // Less specific: only one flag + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); + policy.setFamilyDefault(3, FEE_500); + vm.stopPrank(); + + // Hook has both flags -> matches rule 0 (family 3) first + assertEq(policy.computeFee(key), FEE_500); + vm.snapshotGasLastCall("policy.computeFee - flag-rule multi-flag match"); + } + + function test_flagRule_priorityOrdering() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook only has TAKES_SWAP_SURPLUS (not STABLE_PAIR) + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, + familyId: 3 + }); + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); + policy.setFamilyDefault(3, FEE_500); + vm.stopPrank(); + + // Hook lacks STABLE_PAIR -> skips rule 0, matches rule 1 (family 2) + assertEq(policy.computeFee(key), FEE_200); + } + + function test_flagRule_noMatchFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports ORACLE_BASED but no rules match that + uint256 feeFlags = HookFeeFlags.ORACLE_BASED; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // No rule matches ORACLE_BASED -> falls through to defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_hookReportsZeroFlagsFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports zero flags + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(0); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(1, FEE_500); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // Zero flags -> skips rule matching entirely -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_noRulesConfiguredFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // No flag rules configured -> skips staticcall entirely -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_superset_matchesSubsetRule() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports many flags + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS | HookFeeFlags.STABLE_PAIR + | HookFeeFlags.ORACLE_BASED | HookFeeFlags.YIELD_BEARING; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + // Rule only requires STABLE_PAIR + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 4}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(4, FEE_200); + vm.stopPrank(); + + // Hook has STABLE_PAIR among other flags -> matches + assertEq(policy.computeFee(key), FEE_200); + } + + function test_flagRule_withPairFeeAndMultiplier() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, + familyId: 3 + }); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setPairFee(key.currency0, key.currency1, FEE_200); + policy.setFamilyMultiplier(3, 5000); // 50% + vm.stopPrank(); + + // FEE_200 (200|200) * 50% = (100|100) = FEE_100 + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_governanceOverrideTakesPriorityOverFlags() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); // flag-rule would give this + policy.setHookFamily(hookAddr, 5); // governance override + policy.setFamilyDefault(5, FEE_500); // governance family fee + vm.stopPrank(); + + // Governance override (family 5) wins over flag-rule match (family 2) + assertEq(policy.computeFee(key), FEE_500); + } + + function test_flagRule_max32Rules() public { + FlagRule[] memory rules = new FlagRule[](32); + for (uint256 i; i < 32; ++i) { + rules[i] = FlagRule({requiredFlags: 1 << i, familyId: uint8(i + 1)}); + } + vm.prank(feeSetter); + policy.setFlagRules(rules); + vm.snapshotGasLastCall("policy.setFlagRules - 32 rules (max)"); + assertEq(policy.flagRulesLength(), 32); + } } diff --git a/test/mocks/MockFeeClassifiedHook.sol b/test/mocks/MockFeeClassifiedHook.sol index 26ba3453..13ff103a 100644 --- a/test/mocks/MockFeeClassifiedHook.sol +++ b/test/mocks/MockFeeClassifiedHook.sol @@ -3,30 +3,30 @@ pragma solidity ^0.8.26; import {IFeeClassifiedHook} from "../../src/interfaces/IFeeClassifiedHook.sol"; -/// @notice Mock hook that self-reports its protocol fee family via IFeeClassifiedHook. +/// @notice Mock hook that self-reports behavioral flags via IFeeClassifiedHook. contract MockFeeClassifiedHook is IFeeClassifiedHook { - uint8 public immutable familyId; + uint256 public immutable flags; - constructor(uint8 _familyId) { - familyId = _familyId; + constructor(uint256 _flags) { + flags = _flags; } - function protocolFeeFamily() external view returns (uint8) { - return familyId; + function protocolFeeFlags() external view returns (uint256) { + return flags; } } -/// @notice Mock hook that wastes all gas on protocolFeeFamily() to test griefing protection. +/// @notice Mock hook that wastes all gas on protocolFeeFlags() to test griefing protection. contract GriefingHook is IFeeClassifiedHook { - function protocolFeeFamily() external pure returns (uint8) { - while (true) {} // infinite loop — will consume all gas + function protocolFeeFlags() external pure returns (uint256) { + while (true) {} return 1; // unreachable } } -/// @notice Mock hook that reverts on protocolFeeFamily(). +/// @notice Mock hook that reverts on protocolFeeFlags(). contract RevertingHook { - function protocolFeeFamily() external pure returns (uint8) { + function protocolFeeFlags() external pure returns (uint256) { revert("not classified"); } } From 7dccfb3299888af8d7e1a14e5fa1fc4a0b61fb4a Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 5 May 2026 11:02:13 -0400 Subject: [PATCH 03/26] refactor(v4): replace baseline curve with global pips multiplier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-NativeMath pools now derive their protocol fee as a single global pips multiplier of `key.fee` (denominator 1_000_000, capped at 100% of LP fee, per-direction clamped to MAX_PROTOCOL_FEE). The `pairFees` override is preserved as the higher-priority StaticNativeMath rule, and the entire classified path (hookFamilyId, familyDefaults, familyMultiplierBps, defaultFee, flag rules) is unchanged. The new `_applyLpFeeMultiplier` helper is intentionally distinct from the classified path's `_applyMultiplier` because the denominators differ (1_000_000 pips vs 10_000 bps); reusing the latter would have silently produced fees 100× too large. --- snapshots/V4FeeAdapterForkTest.json | 4 +- snapshots/V4FeeAdapterTest.json | 24 +-- src/feeAdapters/V4FeePolicy.sol | 82 ++++------ src/interfaces/IV4FeePolicy.sol | 71 ++++----- test/V4FeeAdapter.fork.t.sol | 102 +++++------- test/V4FeeAdapter.t.sol | 233 +++++++++++++++++++--------- 6 files changed, 276 insertions(+), 240 deletions(-) diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 1ad14705..94da7e08 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "99787", + "fork: batchTriggerFeeUpdate 3 pools": "91207", "fork: collect 2 currencies": "99567", "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "57915" + "fork: triggerFeeUpdate single pool": "53055" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index e6d9525e..f1ef4d96 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,19 +1,23 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "78027", + "adapter.batchTriggerFeeUpdate - two pools": "72307", "adapter.collect - single currency": "58075", "adapter.getFee - pool override hit": "3329", "adapter.setPoolOverride": "48080", - "adapter.triggerFeeUpdate - single pool": "57855", + "adapter.triggerFeeUpdate - single pool": "52995", "policy.computeFee - classified family default": "10049", - "policy.computeFee - classified griefing hook -> defaultFee": "38465", + "policy.computeFee - classified flag-rule self-report": "19950", + "policy.computeFee - classified griefing hook -> defaultFee": "7677", "policy.computeFee - classified pairFee * multiplier": "8317", - "policy.computeFee - classified self-report": "13414", - "policy.computeFee - classified unclassified -> defaultFee": "6075", - "policy.computeFee - static native math baseline curve": "10840", - "policy.computeFee - static native math pair fee": "3540", - "policy.setBaselineCurve - four breakpoints": "141904", - "policy.setFamilyDefault": "47814", + "policy.computeFee - classified unclassified -> defaultFee": "7677", + "policy.computeFee - flag-rule multi-flag match": "19950", + "policy.computeFee - flag-rule single flag match": "19950", + "policy.computeFee - static native math multiplier": "5980", + "policy.computeFee - static native math pair fee": "3539", + "policy.setFamilyDefault": "47837", "policy.setFamilyMultiplier": "47586", + "policy.setFlagRules - 32 rules (max)": "1493895", + "policy.setFlagRules - two rules": "137715", "policy.setHookFamily": "47567", - "policy.setPairFee": "48696" + "policy.setPairFee": "48697", + "policy.setProtocolFeeMultiplier": "46940" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index d2301ecf..ea3936ad 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -8,14 +8,16 @@ import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; -import {IV4FeePolicy, CurveBreakpoint, FlagRule} from "../interfaces/IV4FeePolicy.sol"; +import {IV4FeePolicy, FlagRule} from "../interfaces/IV4FeePolicy.sol"; import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @title V4FeePolicy /// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification -/// and a baseline fee curve. +/// and a global pips multiplier on `key.fee`. /// @dev Pools are classified into two paths: -/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → baseline curve or pair fee. +/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → pair fee, else +/// `key.fee × protocolFeeMultiplierPips / 1_000_000` per direction (clamped to +/// MAX_PROTOCOL_FEE). /// - Classified: custom accounting or dynamic fee → family multiplier × pair fee, family /// default, or global default fee. /// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). @@ -37,6 +39,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; + /// @dev Denominator for protocolFeeMultiplierPips. 1_000_000 = 100% (matches MAX_LP_FEE). + uint24 internal constant MULTIPLIER_DENOMINATOR = 1_000_000; + /// @inheritdoc IV4FeePolicy IPoolManager public immutable POOL_MANAGER; @@ -58,9 +63,8 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy mapping(bytes32 pairHash => uint24) public pairFees; - /// @dev The baseline curve breakpoints, sorted ascending by lpFeeFloor. Used only by - /// StaticNativeMath pools to map key.fee to a protocol fee. - CurveBreakpoint[] internal _baselineCurve; + /// @inheritdoc IV4FeePolicy + uint24 public protocolFeeMultiplierPips; /// @dev Maximum number of flag rules to bound gas in _resolveFamily. uint256 internal constant MAX_FLAG_RULES = 32; @@ -98,7 +102,8 @@ contract V4FeePolicy is IV4FeePolicy, Owned { // StaticNativeMath: no custom accounting + static fee if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) { uint24 stored = pairFees[ph]; - return stored != 0 ? _decodeFee(stored) : _lookupBaselineFee(key.fee); + if (stored != 0) return _decodeFee(stored); + return _applyLpFeeMultiplier(key.fee, protocolFeeMultiplierPips); } // Classified: custom accounting OR dynamic fee @@ -119,23 +124,6 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return _decodeFee(defaultFee); } - // ─── Curve Getters ─── - - /// @inheritdoc IV4FeePolicy - function baselineCurveLength() external view returns (uint256) { - return _baselineCurve.length; - } - - /// @inheritdoc IV4FeePolicy - function baselineCurve(uint256 index) - external - view - returns (uint24 lpFeeFloor, uint24 protocolFee) - { - CurveBreakpoint storage bp = _baselineCurve[index]; - return (bp.lpFeeFloor, bp.protocolFee); - } - // ─── Flag Rules Getters ─── /// @inheritdoc IV4FeePolicy @@ -204,22 +192,10 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function setBaselineCurve(CurveBreakpoint[] calldata breakpoints) external onlyFeeSetter { - if (breakpoints.length == 0) revert EmptyCurve(); - - // Clear existing curve - delete _baselineCurve; - - uint24 prevFloor; - for (uint256 i; i < breakpoints.length; ++i) { - CurveBreakpoint calldata bp = breakpoints[i]; - if (i != 0 && bp.lpFeeFloor <= prevFloor) revert CurveNotAscending(); - _validateFee(bp.protocolFee); - _baselineCurve.push(bp); - prevFloor = bp.lpFeeFloor; - } - - emit BaselineCurveUpdated(breakpoints.length); + function setProtocolFeeMultiplier(uint24 pips) external onlyFeeSetter { + if (pips > MULTIPLIER_DENOMINATOR) revert MultiplierTooLarge(); + protocolFeeMultiplierPips = pips; + emit ProtocolFeeMultiplierUpdated(pips); } /// @inheritdoc IV4FeePolicy @@ -323,19 +299,23 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return keccak256(abi.encodePacked(Currency.unwrap(c0), Currency.unwrap(c1))); } - /// @dev Walks the baseline curve backward to find the highest floor <= lpFee. - /// Returns 0 if the curve is empty or no breakpoint qualifies. + /// @dev Applies the global pips multiplier to an LP fee, packing the result symmetrically + /// into both 12-bit directional components and clamping each to MAX_PROTOCOL_FEE (1000). /// @param lpFee The pool's LP fee in pips (from key.fee for static fee pools). - /// @return The protocol fee from the matching breakpoint, or 0. - function _lookupBaselineFee(uint24 lpFee) internal view returns (uint24) { - uint256 len = _baselineCurve.length; - if (len == 0) return 0; - - for (uint256 i = len; i != 0; --i) { - CurveBreakpoint storage bp = _baselineCurve[i - 1]; - if (bp.lpFeeFloor <= lpFee) return bp.protocolFee; + /// @param multiplierPips The multiplier in pips. Denominator is MULTIPLIER_DENOMINATOR + /// (1_000_000) — this is distinct from `_applyMultiplier`'s 10_000 bps denominator and + /// the two helpers must not be conflated. + /// @return The packed protocol fee with both 12-bit components equal. + function _applyLpFeeMultiplier(uint24 lpFee, uint24 multiplierPips) + internal + pure + returns (uint24) + { + uint256 perDirection = uint256(lpFee) * multiplierPips / MULTIPLIER_DENOMINATOR; + if (perDirection > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) { + perDirection = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; } - return 0; + return uint24((perDirection << 12) | perDirection); } /// @dev Scales each 12-bit directional fee component by a basis-point multiplier, diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index b8162dde..e101dcd8 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -5,17 +5,6 @@ import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; -/// @dev A breakpoint in the baseline curve. Protocol fee is applied for pools whose LP fee -/// is >= lpFeeFloor. Breakpoints must be stored in ascending order of lpFeeFloor. -struct CurveBreakpoint { - /// @dev The minimum LP fee (in pips) for this breakpoint to apply. Pools with an LP fee - /// >= this value and < the next breakpoint's floor receive this breakpoint's protocolFee. - uint24 lpFeeFloor; - /// @dev The protocol fee to apply. Packed as two 12-bit directional components: - /// lower 12 bits = 0→1 fee, upper 12 bits = 1→0 fee. Each must be <= MAX_PROTOCOL_FEE. - uint24 protocolFee; -} - /// @dev A flag-to-family mapping rule. The policy walks rules in order; the first rule /// whose requiredFlags are all present in the hook's self-reported flags wins. struct FlagRule { @@ -33,7 +22,8 @@ struct FlagRule { /// Family IDs have no hardcoded semantic meaning — labels live in offchain documentation. /// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); /// governance-configured flag rules map flag patterns to families automatically. -/// Static NativeMath pools bypass classification and use the baseline curve directly. +/// Static NativeMath pools bypass classification and derive their protocol fee as a +/// fixed pips multiplier of `key.fee` (per-direction, clamped to MAX_PROTOCOL_FEE). /// Custom-accounting hooks and dynamic fee pools require classification (governance /// override, flag rule match, or defaultFee fallback). /// @custom:security-contact security@uniswap.org @@ -49,11 +39,8 @@ interface IV4FeePolicy { /// @notice Thrown when familyId == 0 is passed to a function that requires > 0. error InvalidFamilyId(); - /// @notice Thrown when setBaselineCurve is called with an empty array. - error EmptyCurve(); - - /// @notice Thrown when baseline curve breakpoints are not in strictly ascending order. - error CurveNotAscending(); + /// @notice Thrown when setProtocolFeeMultiplier is called with pips > 1_000_000. + error MultiplierTooLarge(); /// @notice Thrown when currency0 >= currency1 in setPairFee. error CurrenciesOutOfOrder(); @@ -91,9 +78,9 @@ interface IV4FeePolicy { /// @param feeValue The new pair fee (0 = removed). event PairFeeUpdated(bytes32 indexed pairHash, uint24 feeValue); - /// @notice Emitted when the baseline curve is replaced. - /// @param breakpointCount The number of breakpoints in the new curve. - event BaselineCurveUpdated(uint256 breakpointCount); + /// @notice Emitted when the global protocol fee multiplier is updated. + /// @param multiplierPips The new multiplier in pips (1_000_000 = 100% of LP fee). + event ProtocolFeeMultiplierUpdated(uint24 multiplierPips); /// @notice Emitted when the default classified fee is updated. /// @param feeValue The new default fee (0 = removed). @@ -147,23 +134,17 @@ interface IV4FeePolicy { /// @notice Returns the pair fee for a token pair hash. /// @dev Flat mapping — one fee per pair. StaticNativeMath uses it directly (overrides - /// baseline curve). Classified pools scale it by the family multiplier. + /// the multiplier). Classified pools scale it by the family multiplier. /// @param pairHash The canonical keccak256 hash of the sorted token pair. /// @return The sentinel-encoded pair fee (0 = not set). function pairFees(bytes32 pairHash) external view returns (uint24); - /// @notice Returns the number of breakpoints in the baseline curve. - /// @return The count of breakpoints. - function baselineCurveLength() external view returns (uint256); - - /// @notice Returns the breakpoint at the given index. - /// @param index The zero-based index into the curve array. - /// @return lpFeeFloor The minimum LP fee for this breakpoint. - /// @return protocolFee The protocol fee applied at this breakpoint. - function baselineCurve(uint256 index) - external - view - returns (uint24 lpFeeFloor, uint24 protocolFee); + /// @notice Returns the global multiplier (in pips) applied to `key.fee` on the + /// StaticNativeMath path when no pair fee is set. + /// @dev Pips, where 1_000_000 = 100% of the LP fee. 0 disables the protocol fee on + /// this path. Per-direction result is clamped to MAX_PROTOCOL_FEE. + /// @return The current multiplier in pips. + function protocolFeeMultiplierPips() external view returns (uint24); /// @notice Returns the number of flag rules configured. /// @return The count of flag rules. @@ -190,7 +171,9 @@ interface IV4FeePolicy { /// @notice Computes the protocol fee for a pool. /// @dev Three paths: - /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee or baseline curve. + /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee, or + /// `key.fee × protocolFeeMultiplierPips / 1_000_000` (per direction, clamped to + /// MAX_PROTOCOL_FEE). /// 2. Dynamic fee NativeMath: requires governance familyId (Slot0.lpFee is unreliable). /// 3. CustomAccounting (return-delta flags set): requires governance familyId. /// Paths 2 and 3 fall through to defaultFee if unclassified. @@ -235,13 +218,15 @@ interface IV4FeePolicy { /// @notice Removes the default fee, so unclassified pools return 0. function clearDefaultFee() external; - // --- Curve Configuration (onlyFeeSetter) --- + // --- Multiplier Configuration (onlyFeeSetter) --- - /// @notice Replaces the entire baseline curve with new breakpoints. - /// @dev Breakpoints must be in strictly ascending order of lpFeeFloor. At least one - /// required. Each breakpoint's protocolFee must pass isValidProtocolFee. - /// @param breakpoints The new curve breakpoints, sorted ascending by lpFeeFloor. - function setBaselineCurve(CurveBreakpoint[] calldata breakpoints) external; + /// @notice Sets the global multiplier applied to `key.fee` on the StaticNativeMath + /// path when no pair fee is set. + /// @dev Reverts MultiplierTooLarge if `pips > 1_000_000`. Setting 0 disables the + /// protocol fee on this path (no separate clear function needed — the resulting fee + /// computes to 0 directly). + /// @param pips The multiplier in pips (max 1_000_000 = 100% of LP fee). + function setProtocolFeeMultiplier(uint24 pips) external; // --- Family Defaults & Multipliers (onlyFeeSetter) --- @@ -270,15 +255,15 @@ interface IV4FeePolicy { // --- Pair Fees (onlyFeeSetter) --- /// @notice Sets the pair fee for a token pair. - /// @dev StaticNativeMath pools use this directly (overrides baseline curve). Classified - /// pools scale it by familyMultiplierBps. Setting 0 sets explicit zero. + /// @dev StaticNativeMath pools use this directly (overrides the multiplier). + /// Classified pools scale it by familyMultiplierBps. Setting 0 sets explicit zero. /// Use clearPairFee to remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. /// @param feeValue The pair fee. Must pass isValidProtocolFee if non-zero. function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) external; - /// @notice Removes the pair fee, falling through to the baseline curve. + /// @notice Removes the pair fee, falling through to the multiplier. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. function clearPairFee(Currency currency0, Currency currency1) external; diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol index 5989292e..6e078bc7 100644 --- a/test/V4FeeAdapter.fork.t.sol +++ b/test/V4FeeAdapter.fork.t.sol @@ -18,7 +18,6 @@ import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; -import {CurveBreakpoint} from "../src/interfaces/IV4FeePolicy.sol"; /// @notice Integration tests using a real v4 PoolManager (deployed locally via Deployers). /// Verifies protocol fee accrual from real swaps, collection to TokenJar, and the full @@ -92,13 +91,9 @@ contract V4FeeAdapterForkTest is Deployers { // ============ End-to-End: Set Fee -> Swap -> Accrue -> Collect ============ function test_e2e_setFee_swap_collect() public { - // Configure baseline curve - CurveBreakpoint[] memory curve = new CurveBreakpoint[](3); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); - curve[1] = CurveBreakpoint({lpFeeFloor: 1000, protocolFee: PROTO_FEE_200}); - curve[2] = CurveBreakpoint({lpFeeFloor: 5000, protocolFee: PROTO_FEE_500}); + // 66_667 pips × pool3000.fee (3000) / 1_000_000 = 200 per direction (= PROTO_FEE_200) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(66_667); // Trigger fee update on the 3000 bps pool adapter.triggerFeeUpdate(pool3000); @@ -136,17 +131,16 @@ contract V4FeeAdapterForkTest is Deployers { assertEq(manager.protocolFeesAccrued(currency1), 0); } - // ============ Baseline Curve: Different Tiers Get Different Fees ============ + // ============ Multiplier: Pools Scale Linearly With LP Fee ============ - function test_baselineCurve_differentPoolsDifferentFees() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](3); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); - curve[1] = CurveBreakpoint({lpFeeFloor: 1000, protocolFee: PROTO_FEE_300}); - curve[2] = CurveBreakpoint({lpFeeFloor: 5000, protocolFee: PROTO_FEE_500}); + function test_multiplier_differentPoolsLinearlyScaled() public { + // multiplier = 100_000 (10% of LP fee) + // pool500 (LP 500) -> 50 per direction + // pool3000 (LP 3000) -> 300 per direction (= PROTO_FEE_300) + // pool10000 (LP 10_000) -> 1000 per direction, clamped to MAX_PROTOCOL_FEE vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); - // Trigger all pools PoolKey[] memory keys = new PoolKey[](3); keys[0] = pool500; keys[1] = pool3000; @@ -154,27 +148,24 @@ contract V4FeeAdapterForkTest is Deployers { adapter.batchTriggerFeeUpdate(keys); vm.snapshotGasLastCall("fork: batchTriggerFeeUpdate 3 pools"); - // 500 bps pool -> floor 0 matches -> PROTO_FEE_100 + uint24 expected50 = (50 << 12) | 50; (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); - assertEq(fee500, PROTO_FEE_100); + assertEq(fee500, expected50); - // 3000 bps pool -> floor 1000 matches -> PROTO_FEE_300 (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); assertEq(fee3000, PROTO_FEE_300); - // 10000 bps pool -> floor 5000 matches -> PROTO_FEE_500 + uint24 expected1000 = (1000 << 12) | 1000; (,, uint24 fee10000,) = manager.getSlot0(pool10000.toId()); - assertEq(fee10000, PROTO_FEE_500); + assertEq(fee10000, expected1000); } // ============ Pool Override Bypasses Policy ============ - function test_poolOverride_bypassesBaselineCurve() public { - // Set baseline curve - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + function test_poolOverride_bypassesPolicy() public { + // 200_000 pips × pool500.fee (500) / 1_000_000 = 100 per direction (= PROTO_FEE_100) vm.startPrank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(200_000); // Override one pool to PROTO_FEE_500 adapter.setPoolOverride(pool3000.toId(), PROTO_FEE_500); @@ -184,28 +175,27 @@ contract V4FeeAdapterForkTest is Deployers { adapter.triggerFeeUpdate(pool3000); adapter.triggerFeeUpdate(pool500); - // pool3000 gets the override + // pool3000 gets the override (multiplier-derived value would have been higher) (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); assertEq(fee3000, PROTO_FEE_500); - // pool500 gets the baseline + // pool500 gets the multiplier-derived fee (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); assertEq(fee500, PROTO_FEE_100); } - // ============ Pair Fee Overrides Curve ============ + // ============ Pair Fee Overrides Multiplier ============ - function test_pairFee_overridesBaselineCurve() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + function test_pairFee_overridesMultiplier() public { vm.startPrank(feeSetter); - policy.setBaselineCurve(curve); + // Any non-zero multiplier — pair fee should win regardless + policy.setProtocolFeeMultiplier(100_000); policy.setPairFee(currency0, currency1, PROTO_FEE_300); vm.stopPrank(); adapter.triggerFeeUpdate(pool3000); - // Pair fee takes precedence over baseline + // Pair fee takes precedence over the multiplier (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); assertEq(fee, PROTO_FEE_300); } @@ -213,10 +203,9 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Fees Accrue From Multiple Swaps ============ function test_feesAccrueFromMultipleSwaps() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); adapter.triggerFeeUpdate(pool3000); @@ -243,29 +232,26 @@ contract V4FeeAdapterForkTest is Deployers { assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), accrued1); } - // ============ Fee Update After Curve Change ============ + // ============ Fee Update After Multiplier Change ============ - function test_curveChange_requiresRetrigger() public { - // Set initial curve - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_100}); + function test_multiplierChange_requiresRetrigger() public { + // 33_334 pips × 3000 / 1_000_000 = 100 per direction (= PROTO_FEE_100, integer-truncated) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(33_334); adapter.triggerFeeUpdate(pool3000); (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); assertEq(feeBefore, PROTO_FEE_100); - // Change curve - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_500}); + // 166_667 pips × 3000 / 1_000_000 = 500 per direction (= PROTO_FEE_500, integer-truncated) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(166_667); // Pool still has old fee until retriggered (,, uint24 feeStale,) = manager.getSlot0(pool3000.toId()); assertEq(feeStale, PROTO_FEE_100); - // Retrigger picks up new curve + // Retrigger picks up new multiplier adapter.triggerFeeUpdate(pool3000); (,, uint24 feeAfter,) = manager.getSlot0(pool3000.toId()); assertEq(feeAfter, PROTO_FEE_500); @@ -274,17 +260,15 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Policy Swap ============ function test_policySwap_newPolicyTakesEffect() public { - // Set up initial policy with fees - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); adapter.triggerFeeUpdate(pool3000); (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); assertEq(feeBefore, PROTO_FEE_300); - // Deploy new policy with no curve (everything returns 0) + // Deploy new policy with no multiplier configured (default 0, everything returns 0) V4FeePolicy newPolicy = new V4FeePolicy(manager); adapter.setPolicy(newPolicy); @@ -297,11 +281,9 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Explicit Zero Override Prevents Fee Accrual ============ function test_explicitZeroOverride_preventsFeeAccrual() public { - // Set baseline curve with real fees - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.startPrank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); // Override pool to explicit zero adapter.setPoolOverride(pool3000.toId(), 0); @@ -322,10 +304,9 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Clear Override Restores Policy Behavior ============ function test_clearOverride_restoresPolicy() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.startPrank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); adapter.setPoolOverride(pool3000.toId(), 0); // explicit zero vm.stopPrank(); @@ -349,10 +330,9 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Partial Collection ============ function test_partialCollection() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: PROTO_FEE_300}); + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(100_000); adapter.triggerFeeUpdate(pool3000); // Swap to accrue fees diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index e25d4076..2cf18f3e 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -13,7 +13,7 @@ import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; -import {CurveBreakpoint, FlagRule} from "../src/interfaces/IV4FeePolicy.sol"; +import {FlagRule} from "../src/interfaces/IV4FeePolicy.sol"; import {HookFeeFlags} from "../src/libraries/HookFeeFlags.sol"; import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { @@ -111,13 +111,13 @@ contract V4FeeAdapterTest is Test { // ============ Helpers ============ - function _buildCurve() internal pure returns (CurveBreakpoint[] memory) { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](4); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: FEE_100}); - curve[1] = CurveBreakpoint({lpFeeFloor: 500, protocolFee: FEE_200}); - curve[2] = CurveBreakpoint({lpFeeFloor: 3000, protocolFee: FEE_300}); - curve[3] = CurveBreakpoint({lpFeeFloor: 10_000, protocolFee: FEE_500}); - return curve; + /// @dev Multiplier value chosen so that `standardKey.fee = 3000` yields + /// `FEE_300` (300 pips per direction): 3000 × 100_000 / 1_000_000 = 300. + uint24 internal constant TEST_MULTIPLIER_PIPS = 100_000; + + function _setMultiplier(uint24 pips) internal { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(pips); } function _pairHash() internal view returns (bytes32) { @@ -201,7 +201,7 @@ contract V4FeeAdapterTest is Test { // Configure policy to return FEE_300 vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); assertEq(adapter.getFee(standardKey), FEE_300); // Set pool override to explicit zero -should NOT fall through to policy @@ -217,7 +217,7 @@ contract V4FeeAdapterTest is Test { PoolId id = standardKey.toId(); vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); // Set override then clear it vm.startPrank(feeSetter); @@ -253,9 +253,9 @@ contract V4FeeAdapterTest is Test { } function test_poolOverride_takesPriorityOverPolicy() public { - // Configure policy to return FEE_300 via baseline curve + // Configure policy to return FEE_300 via the multiplier path vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); // Set pool override to FEE_500 adapter.setPoolOverride(standardKey.toId(), FEE_500); vm.stopPrank(); @@ -268,7 +268,7 @@ contract V4FeeAdapterTest is Test { function test_triggerFeeUpdate_success() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); vm.expectEmit(true, true, false, true, address(adapter)); emit IV4FeeAdapter.FeeUpdateTriggered(alice, standardKey.toId(), FEE_300); @@ -295,7 +295,7 @@ contract V4FeeAdapterTest is Test { function testFuzz_triggerFeeUpdate_permissionless(address caller) public { vm.assume(caller != address(0)); vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); vm.prank(caller); adapter.triggerFeeUpdate(standardKey); @@ -304,7 +304,7 @@ contract V4FeeAdapterTest is Test { function test_batchTriggerFeeUpdate_success() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); PoolKey[] memory keys = new PoolKey[](2); keys[0] = standardKey; @@ -404,110 +404,197 @@ contract V4FeeAdapterTest is Test { assertEq(policy.isCustomAccounting(address(addr)), expected); } - // ============ Policy: Baseline Curve ============ + // ============ Policy: Protocol Fee Multiplier ============ - function test_setBaselineCurve_success() public { - CurveBreakpoint[] memory curve = _buildCurve(); + function test_setProtocolFeeMultiplier_success() public { vm.expectEmit(false, false, false, true, address(policy)); - emit IV4FeePolicy.BaselineCurveUpdated(4); + emit IV4FeePolicy.ProtocolFeeMultiplierUpdated(TEST_MULTIPLIER_PIPS); vm.prank(feeSetter); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); - vm.snapshotGasLastCall("policy.setBaselineCurve - four breakpoints"); - assertEq(policy.baselineCurveLength(), 4); - (uint24 floor, uint24 fee) = policy.baselineCurve(2); - assertEq(floor, 3000); - assertEq(fee, FEE_300); + vm.snapshotGasLastCall("policy.setProtocolFeeMultiplier"); + assertEq(policy.protocolFeeMultiplierPips(), TEST_MULTIPLIER_PIPS); } - function test_setBaselineCurve_revertsEmpty() public { - CurveBreakpoint[] memory empty = new CurveBreakpoint[](0); + function test_setProtocolFeeMultiplier_revertsTooLarge() public { vm.prank(feeSetter); - vm.expectRevert(IV4FeePolicy.EmptyCurve.selector); - policy.setBaselineCurve(empty); + vm.expectRevert(IV4FeePolicy.MultiplierTooLarge.selector); + policy.setProtocolFeeMultiplier(1_000_001); } - function test_setBaselineCurve_revertsNotAscending() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](2); - curve[0] = CurveBreakpoint({lpFeeFloor: 3000, protocolFee: FEE_300}); - curve[1] = CurveBreakpoint({lpFeeFloor: 500, protocolFee: FEE_200}); // not ascending + function test_setProtocolFeeMultiplier_acceptsBoundary() public { vm.prank(feeSetter); - vm.expectRevert(IV4FeePolicy.CurveNotAscending.selector); - policy.setBaselineCurve(curve); + policy.setProtocolFeeMultiplier(1_000_000); + assertEq(policy.protocolFeeMultiplierPips(), 1_000_000); } - function test_setBaselineCurve_revertsInvalidFee() public { - CurveBreakpoint[] memory curve = new CurveBreakpoint[](1); - curve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: (1001 << 12) | 1001}); - vm.prank(feeSetter); - vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); - policy.setBaselineCurve(curve); + function test_setProtocolFeeMultiplier_acceptsZero() public { + vm.startPrank(feeSetter); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setProtocolFeeMultiplier(0); + vm.stopPrank(); + assertEq(policy.protocolFeeMultiplierPips(), 0); } - function test_setBaselineCurve_revertsUnauthorized() public { + function test_setProtocolFeeMultiplier_revertsUnauthorized() public { vm.prank(alice); vm.expectRevert(IV4FeePolicy.Unauthorized.selector); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); } - function test_setBaselineCurve_replacesExisting() public { + function test_setProtocolFeeMultiplier_replacesExisting() public { vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); - assertEq(policy.baselineCurveLength(), 4); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + assertEq(policy.protocolFeeMultiplierPips(), TEST_MULTIPLIER_PIPS); - CurveBreakpoint[] memory newCurve = new CurveBreakpoint[](1); - newCurve[0] = CurveBreakpoint({lpFeeFloor: 0, protocolFee: FEE_100}); - policy.setBaselineCurve(newCurve); - assertEq(policy.baselineCurveLength(), 1); + policy.setProtocolFeeMultiplier(200_000); + assertEq(policy.protocolFeeMultiplierPips(), 200_000); vm.stopPrank(); } // ============ Policy: computeFee - static native math path ============ - function test_computeFee_staticNativeMath_baselineCurve() public { + function test_computeFee_staticNativeMath_multiplier() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); - // key.fee = 3000 -> should match the 3000 breakpoint -> FEE_300 + // key.fee = 3000, multiplier 100_000 (10%) -> 3000 * 100_000 / 1_000_000 = 300 assertEq(policy.computeFee(standardKey), FEE_300); - vm.snapshotGasLastCall("policy.computeFee - static native math baseline curve"); + vm.snapshotGasLastCall("policy.computeFee - static native math multiplier"); } - function test_computeFee_staticNativeMath_baselineCurve_lowFee() public { + function test_computeFee_staticNativeMath_multiplier_lowFee() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); PoolKey memory lowFeeKey = standardKey; lowFeeKey.fee = 100; poolManager.mockInitialize(lowFeeKey); - // key.fee = 100, floor 0 matches -> FEE_100 - assertEq(policy.computeFee(lowFeeKey), FEE_100); + // key.fee = 100, multiplier 100_000 -> 100 * 100_000 / 1_000_000 = 10 per direction + uint24 expected = (10 << 12) | 10; + assertEq(policy.computeFee(lowFeeKey), expected); } - function test_computeFee_staticNativeMath_pairFeeOverridesCurve() public { + function test_computeFee_staticNativeMath_pairFeeOverridesMultiplier() public { vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); vm.stopPrank(); - // Pair fee should override baseline curve + // Pair fee should override the multiplier-derived fee assertEq(policy.computeFee(standardKey), FEE_500); vm.snapshotGasLastCall("policy.computeFee - static native math pair fee"); } - function test_computeFee_staticNativeMath_emptyCurveReturnsZero() public view { + function test_computeFee_staticNativeMath_unsetMultiplierReturnsZero() public view { assertEq(policy.computeFee(standardKey), 0); } function test_computeFee_staticNativeMath_hookWithoutDeltaFlags() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); // hookKey has address with bit 7 set but bits 0-3 clear -> StaticNativeMath path assertEq(policy.computeFee(hookKey), FEE_300); } + // ============ Policy: computeFee multiplier math ============ + + function test_computeFee_staticNativeMath_zeroMultiplier() public { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(0); + assertEq(policy.computeFee(standardKey), 0); + } + + function test_computeFee_staticNativeMath_zeroLpFee() public { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(1_000_000); + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + + // 0 * any multiplier = 0; LP fee of 0 means no protocol fee regardless of multiplier + assertEq(policy.computeFee(zeroFeeKey), 0); + } + + function test_computeFee_staticNativeMath_multiplier10pct() public { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(100_000); // 10% of LP fee + + PoolKey memory k = standardKey; + k.fee = 100; + poolManager.mockInitialize(k); + + // 100 * 100_000 / 1_000_000 = 10 per direction (matches user's example) + uint24 expected = (10 << 12) | 10; + assertEq(policy.computeFee(k), expected); + } + + function test_computeFee_staticNativeMath_multiplier100pct() public { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(1_000_000); // 100% of LP fee + + PoolKey memory k = standardKey; + k.fee = 1000; + poolManager.mockInitialize(k); + + // 1000 * 1_000_000 / 1_000_000 = 1000 per direction (clamp boundary, no truncation) + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_multiplierClamps() public { + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(1_000_000); + + PoolKey memory k = standardKey; + k.fee = 5000; + poolManager.mockInitialize(k); + + // 5000 * 1_000_000 / 1_000_000 = 5000, clamped to MAX_PROTOCOL_FEE = 1000 per direction + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_pairFeeBeatsMultiplier() public { + vm.startPrank(feeSetter); + policy.setProtocolFeeMultiplier(1_000_000); + policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_200); + vm.stopPrank(); + + // Pair fee wins over multiplier-derived fee + assertEq(policy.computeFee(standardKey), FEE_200); + } + + function test_computeFee_dynamicFee_skipsMultiplier() public { + // Regression: a future routing bug must not multiply DYNAMIC_FEE_FLAG (0x800000) + // on the StaticNativeMath path. Dynamic-fee pools must take the classified path. + vm.startPrank(feeSetter); + policy.setProtocolFeeMultiplier(1_000_000); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // dynamicKey.fee == LPFeeLibrary.DYNAMIC_FEE_FLAG -> classified -> defaultFee + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function testFuzz_computeFee_staticNativeMath_multiplier(uint24 lpFee, uint24 pips) public { + lpFee = uint24(bound(lpFee, 0, LPFeeLibrary.MAX_LP_FEE)); + pips = uint24(bound(pips, 0, 1_000_000)); + vm.prank(feeSetter); + policy.setProtocolFeeMultiplier(pips); + + PoolKey memory k = standardKey; + k.fee = lpFee; + poolManager.mockInitialize(k); + + uint24 fee = policy.computeFee(k); + uint24 zeroForOne = fee & 0xFFF; + uint24 oneForZero = fee >> 12; + assertEq(zeroForOne, oneForZero); // symmetric: both 12-bit components equal + assertLe(zeroForOne, ProtocolFeeLibrary.MAX_PROTOCOL_FEE); + } + // ============ Policy: computeFee - classified path ============ function test_computeFee_classified_familyDefault() public { @@ -524,7 +611,6 @@ contract V4FeeAdapterTest is Test { poolManager.mockInitialize(customKey); vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); policy.setHookFamily(customHook, 1); policy.setFamilyDefault(1, FEE_200); vm.stopPrank(); @@ -914,9 +1000,9 @@ contract V4FeeAdapterTest is Test { vm.stopPrank(); } - function test_clearPairFee_fallsThroughToCurve() public { + function test_clearPairFee_fallsThroughToMultiplier() public { vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); vm.stopPrank(); @@ -926,7 +1012,8 @@ contract V4FeeAdapterTest is Test { policy.clearPairFee(standardKey.currency0, standardKey.currency1); assertEq(policy.pairFees(_pairHash()), 0); // storage deleted - assertEq(policy.computeFee(standardKey), FEE_300); // back to baseline curve + // Falls through to multiplier: 3000 * 100_000 / 1_000_000 = 300 + assertEq(policy.computeFee(standardKey), FEE_300); } function test_clearPairFee_revertsCurrenciesOutOfOrder() public { @@ -949,7 +1036,7 @@ contract V4FeeAdapterTest is Test { poolManager.mockInitialize(customKey); vm.startPrank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); policy.setDefaultFee(FEE_100); policy.setHookFamily(customHook, 1); policy.setFamilyDefault(1, FEE_200); @@ -957,7 +1044,7 @@ contract V4FeeAdapterTest is Test { policy.setFamilyMultiplier(1, 10_000); // 1x vm.stopPrank(); - // StandardKey -> StaticNativeMath -> pair fee overrides curve -> FEE_300 + // StandardKey -> StaticNativeMath -> pair fee overrides multiplier -> FEE_300 assertEq(adapter.getFee(standardKey), FEE_300); // CustomKey -> Classified -> pair fee × multiplier -> FEE_300 × 1x = FEE_300 @@ -976,7 +1063,7 @@ contract V4FeeAdapterTest is Test { poolManager.setProtocolFeesAccrued(c, amount); vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); // Trigger fee update adapter.triggerFeeUpdate(standardKey); @@ -1008,18 +1095,18 @@ contract V4FeeAdapterTest is Test { function test_edge_policySwap() public { vm.prank(feeSetter); - policy.setBaselineCurve(_buildCurve()); + policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); assertEq(adapter.getFee(standardKey), FEE_300); - // Deploy new policy with different curve + // Deploy new policy with no multiplier configured (default 0) vm.startPrank(owner); V4FeePolicy newPolicy = new V4FeePolicy(IPoolManager(address(poolManager))); newPolicy.setFeeSetter(feeSetter); adapter.setPolicy(newPolicy); vm.stopPrank(); - // New policy has no curve -> 0 + // New policy has multiplier == 0 -> 0 assertEq(adapter.getFee(standardKey), 0); } From a2588c66368e26d3a6680a0407a2e09bc0a9cc4c Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 5 May 2026 11:20:32 -0400 Subject: [PATCH 04/26] refactor(v4): unify classified-path multiplier units to pips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `familyMultiplierBps` (denominator 10_000) becomes `familyMultiplierPips` (denominator 1_000_000), sharing MULTIPLIER_DENOMINATOR with `protocolFeeMultiplierPips`. Storage widens uint16 → uint24 to fit 1_000_000. The setter reuses MultiplierTooLarge to enforce the new 100% cap. The clamp inside `_applyMultiplier` is removed: pairFees are validated ≤ MAX_PROTOCOL_FEE per direction at write time, and `multiplierPips` is now bounded ≤ 1_000_000 by `setFamilyMultiplier`, so the product divided by the denominator can never exceed MAX_PROTOCOL_FEE per direction. The `_applyLpFeeMultiplier` clamp stays — `key.fee` is not bounded by the policy and can drive the result above the cap. --- snapshots/V4FeeAdapterForkTest.json | 4 +-- snapshots/V4FeeAdapterTest.json | 24 ++++++++--------- src/feeAdapters/V4FeePolicy.sol | 41 ++++++++++++++++------------- src/interfaces/IV4FeePolicy.sol | 26 +++++++++--------- test/V4FeeAdapter.t.sol | 38 +++++++++++++++++--------- 5 files changed, 76 insertions(+), 57 deletions(-) diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 94da7e08..6428d75b 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "91207", + "fork: batchTriggerFeeUpdate 3 pools": "91339", "fork: collect 2 currencies": "99567", "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "53055" + "fork: triggerFeeUpdate single pool": "53099" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index f1ef4d96..3f7a632f 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,20 +1,20 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "72307", + "adapter.batchTriggerFeeUpdate - two pools": "72395", "adapter.collect - single currency": "58075", "adapter.getFee - pool override hit": "3329", "adapter.setPoolOverride": "48080", - "adapter.triggerFeeUpdate - single pool": "52995", - "policy.computeFee - classified family default": "10049", - "policy.computeFee - classified flag-rule self-report": "19950", - "policy.computeFee - classified griefing hook -> defaultFee": "7677", - "policy.computeFee - classified pairFee * multiplier": "8317", - "policy.computeFee - classified unclassified -> defaultFee": "7677", - "policy.computeFee - flag-rule multi-flag match": "19950", - "policy.computeFee - flag-rule single flag match": "19950", - "policy.computeFee - static native math multiplier": "5980", - "policy.computeFee - static native math pair fee": "3539", + "adapter.triggerFeeUpdate - single pool": "53039", + "policy.computeFee - classified family default": "10090", + "policy.computeFee - classified flag-rule self-report": "19991", + "policy.computeFee - classified griefing hook -> defaultFee": "7721", + "policy.computeFee - classified pairFee * multiplier": "8312", + "policy.computeFee - classified unclassified -> defaultFee": "7721", + "policy.computeFee - flag-rule multi-flag match": "19991", + "policy.computeFee - flag-rule single flag match": "19991", + "policy.computeFee - static native math multiplier": "6024", + "policy.computeFee - static native math pair fee": "3583", "policy.setFamilyDefault": "47837", - "policy.setFamilyMultiplier": "47586", + "policy.setFamilyMultiplier": "47662", "policy.setFlagRules - 32 rules (max)": "1493895", "policy.setFlagRules - two rules": "137715", "policy.setHookFamily": "47567", diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index ea3936ad..7b815ada 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -58,7 +58,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { mapping(uint8 familyId => uint24) public familyDefaults; /// @inheritdoc IV4FeePolicy - mapping(uint8 familyId => uint16) public familyMultiplierBps; + mapping(uint8 familyId => uint24) public familyMultiplierPips; /// @inheritdoc IV4FeePolicy mapping(bytes32 pairHash => uint24) public pairFees; @@ -111,7 +111,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint8 family = _resolveFamily(hook); if (family != 0) { uint24 pairFee = pairFees[ph]; - uint16 multiplier = familyMultiplierBps[family]; + uint24 multiplier = familyMultiplierPips[family]; if (pairFee != 0 && multiplier != 0) { return _applyMultiplier(_decodeFee(pairFee), multiplier); @@ -213,15 +213,16 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function setFamilyMultiplier(uint8 familyId, uint16 multiplierBps) external onlyFeeSetter { + function setFamilyMultiplier(uint8 familyId, uint24 multiplierPips) external onlyFeeSetter { if (familyId == 0) revert InvalidFamilyId(); - familyMultiplierBps[familyId] = multiplierBps; - emit FamilyMultiplierUpdated(familyId, multiplierBps); + if (multiplierPips > MULTIPLIER_DENOMINATOR) revert MultiplierTooLarge(); + familyMultiplierPips[familyId] = multiplierPips; + emit FamilyMultiplierUpdated(familyId, multiplierPips); } /// @inheritdoc IV4FeePolicy function clearFamilyMultiplier(uint8 familyId) external onlyFeeSetter { - delete familyMultiplierBps[familyId]; + delete familyMultiplierPips[familyId]; emit FamilyMultiplierUpdated(familyId, 0); } @@ -301,10 +302,12 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @dev Applies the global pips multiplier to an LP fee, packing the result symmetrically /// into both 12-bit directional components and clamping each to MAX_PROTOCOL_FEE (1000). + /// Shares MULTIPLIER_DENOMINATOR (1_000_000) with `_applyMultiplier`. Distinct from that + /// helper because this one constructs a symmetric packed fee from a single LP fee value + /// and must clamp (LP fees can exceed MAX_PROTOCOL_FEE), whereas `_applyMultiplier` + /// rescales an already-validated packed protocol fee per direction and needs no clamp. /// @param lpFee The pool's LP fee in pips (from key.fee for static fee pools). - /// @param multiplierPips The multiplier in pips. Denominator is MULTIPLIER_DENOMINATOR - /// (1_000_000) — this is distinct from `_applyMultiplier`'s 10_000 bps denominator and - /// the two helpers must not be conflated. + /// @param multiplierPips The multiplier in pips (max MULTIPLIER_DENOMINATOR = 1_000_000). /// @return The packed protocol fee with both 12-bit components equal. function _applyLpFeeMultiplier(uint24 lpFee, uint24 multiplierPips) internal @@ -318,17 +321,17 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return uint24((perDirection << 12) | perDirection); } - /// @dev Scales each 12-bit directional fee component by a basis-point multiplier, - /// clamping each to MAX_PROTOCOL_FEE (1000). The two 12-bit components are extracted, - /// scaled independently, and repacked into a single uint24. + /// @dev Scales each 12-bit directional fee component by a pips multiplier. The two + /// 12-bit components are extracted, scaled independently, and repacked into a single + /// uint24. Shares MULTIPLIER_DENOMINATOR with `_applyLpFeeMultiplier`. No clamp is + /// needed: pairFees are validated <= MAX_PROTOCOL_FEE per direction at write time, + /// and `multiplierPips` is bounded by `setFamilyMultiplier` to <= 1_000_000. /// @param baseFee The base protocol fee (two 12-bit directional components packed). - /// @param multiplierBps The multiplier in basis points (10000 = 1x, 5000 = 0.5x). - /// @return The scaled and clamped protocol fee. - function _applyMultiplier(uint24 baseFee, uint16 multiplierBps) internal pure returns (uint24) { - uint256 fee0 = uint256(baseFee & 0xFFF) * multiplierBps / 10_000; - uint256 fee1 = uint256(baseFee >> 12) * multiplierBps / 10_000; - if (fee0 > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) fee0 = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; - if (fee1 > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) fee1 = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; + /// @param multiplierPips The multiplier in pips (max 1_000_000 = 100%). + /// @return The scaled protocol fee. + function _applyMultiplier(uint24 baseFee, uint24 multiplierPips) internal pure returns (uint24) { + uint256 fee0 = uint256(baseFee & 0xFFF) * multiplierPips / MULTIPLIER_DENOMINATOR; + uint256 fee1 = uint256(baseFee >> 12) * multiplierPips / MULTIPLIER_DENOMINATOR; return uint24((fee1 << 12) | fee0); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index e101dcd8..625ad827 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -70,8 +70,8 @@ interface IV4FeePolicy { /// @notice Emitted when a family's multiplier is updated. /// @param familyId The family whose multiplier was changed. - /// @param multiplierBps The new multiplier in basis points (0 = removed). - event FamilyMultiplierUpdated(uint8 indexed familyId, uint16 multiplierBps); + /// @param multiplierPips The new multiplier in pips (0 = removed, 1_000_000 = 100%). + event FamilyMultiplierUpdated(uint8 indexed familyId, uint24 multiplierPips); /// @notice Emitted when a pair fee is updated. /// @param pairHash The canonical hash of the token pair. @@ -126,11 +126,13 @@ interface IV4FeePolicy { /// @return The sentinel-encoded default fee for the family. function familyDefaults(uint8 familyId) external view returns (uint24); - /// @notice Returns the multiplier (basis points) for a given family ID. - /// @dev 10000 = 1x, 5000 = 0.5x. Applied to pairFees to derive a scaled fee. + /// @notice Returns the multiplier (in pips) for a given family ID. + /// @dev 1_000_000 = 100% (1x), 500_000 = 50% (0.5x). Applied to pairFees to derive a + /// scaled fee on the classified path. Shares the same denominator + /// (MULTIPLIER_DENOMINATOR = 1_000_000) as protocolFeeMultiplierPips. /// @param familyId The family to query. - /// @return The multiplier in basis points (0 = not set). - function familyMultiplierBps(uint8 familyId) external view returns (uint16); + /// @return The multiplier in pips (0 = not set). + function familyMultiplierPips(uint8 familyId) external view returns (uint24); /// @notice Returns the pair fee for a token pair hash. /// @dev Flat mapping — one fee per pair. StaticNativeMath uses it directly (overrides @@ -241,12 +243,12 @@ interface IV4FeePolicy { /// @param familyId The family to clear. function clearFamilyDefault(uint8 familyId) external; - /// @notice Sets a multiplier for a family, applied to pairFees. - /// @dev familyId must be > 0. multiplierBps in basis points (10000 = 1x). - /// The scaled fee is clamped so each 12-bit component <= MAX_PROTOCOL_FEE. + /// @notice Sets a multiplier for a family, applied to pairFees on the classified path. + /// @dev familyId must be > 0. multiplierPips in pips (1_000_000 = 100% = 1x). + /// Reverts MultiplierTooLarge if `multiplierPips > 1_000_000`. /// @param familyId The family to configure. - /// @param multiplierBps The multiplier in basis points. - function setFamilyMultiplier(uint8 familyId, uint16 multiplierBps) external; + /// @param multiplierPips The multiplier in pips (max 1_000_000 = 100%). + function setFamilyMultiplier(uint8 familyId, uint24 multiplierPips) external; /// @notice Removes the multiplier for a family. /// @param familyId The family to clear. @@ -256,7 +258,7 @@ interface IV4FeePolicy { /// @notice Sets the pair fee for a token pair. /// @dev StaticNativeMath pools use this directly (overrides the multiplier). - /// Classified pools scale it by familyMultiplierBps. Setting 0 sets explicit zero. + /// Classified pools scale it by familyMultiplierPips. Setting 0 sets explicit zero. /// Use clearPairFee to remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 2cf18f3e..2f816e07 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -633,7 +633,7 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); policy.setPairFee(customKey.currency0, customKey.currency1, FEE_200); - policy.setFamilyMultiplier(1, 5000); // 50% + policy.setFamilyMultiplier(1, 500_000); // 50% vm.stopPrank(); // FEE_200 = 200|200, multiplied by 50% = 100|100 = FEE_100 @@ -641,7 +641,7 @@ contract V4FeeAdapterTest is Test { vm.snapshotGasLastCall("policy.computeFee - classified pairFee * multiplier"); } - function test_computeFee_classified_multiplierClamps() public { + function test_computeFee_classified_pairFeeAtMaxMultiplier() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ currency0: Currency.wrap(address(token0)), @@ -655,9 +655,11 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); policy.setPairFee(customKey.currency0, customKey.currency1, FEE_1000); - policy.setFamilyMultiplier(1, 20_000); // 2x -> would be 2000, clamped to 1000 + policy.setFamilyMultiplier(1, 1_000_000); // 100% (1x) -- the new ceiling vm.stopPrank(); + // FEE_1000 (1000|1000) * 1x = FEE_1000; setter validation guarantees the result + // can never exceed MAX_PROTOCOL_FEE per direction. assertEq(policy.computeFee(customKey), FEE_1000); } @@ -884,17 +886,29 @@ contract V4FeeAdapterTest is Test { function test_setFamilyMultiplier_success() public { vm.expectEmit(true, false, false, true, address(policy)); - emit IV4FeePolicy.FamilyMultiplierUpdated(2, 5000); + emit IV4FeePolicy.FamilyMultiplierUpdated(2, 500_000); vm.prank(feeSetter); - policy.setFamilyMultiplier(2, 5000); + policy.setFamilyMultiplier(2, 500_000); vm.snapshotGasLastCall("policy.setFamilyMultiplier"); - assertEq(policy.familyMultiplierBps(2), 5000); + assertEq(policy.familyMultiplierPips(2), 500_000); } function test_setFamilyMultiplier_revertsZeroFamily() public { vm.prank(feeSetter); vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); - policy.setFamilyMultiplier(0, 10_000); + policy.setFamilyMultiplier(0, 100_000); + } + + function test_setFamilyMultiplier_revertsTooLarge() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.MultiplierTooLarge.selector); + policy.setFamilyMultiplier(1, 1_000_001); + } + + function test_setFamilyMultiplier_acceptsBoundary() public { + vm.prank(feeSetter); + policy.setFamilyMultiplier(1, 1_000_000); + assertEq(policy.familyMultiplierPips(1), 1_000_000); } function test_setPairFee_success() public { @@ -992,11 +1006,11 @@ contract V4FeeAdapterTest is Test { function test_clearFamilyMultiplier() public { vm.startPrank(feeSetter); - policy.setFamilyMultiplier(1, 5000); - assertEq(policy.familyMultiplierBps(1), 5000); + policy.setFamilyMultiplier(1, 500_000); + assertEq(policy.familyMultiplierPips(1), 500_000); policy.clearFamilyMultiplier(1); - assertEq(policy.familyMultiplierBps(1), 0); + assertEq(policy.familyMultiplierPips(1), 0); vm.stopPrank(); } @@ -1041,7 +1055,7 @@ contract V4FeeAdapterTest is Test { policy.setHookFamily(customHook, 1); policy.setFamilyDefault(1, FEE_200); policy.setPairFee(customKey.currency0, customKey.currency1, FEE_300); - policy.setFamilyMultiplier(1, 10_000); // 1x + policy.setFamilyMultiplier(1, 1_000_000); // 1x vm.stopPrank(); // StandardKey -> StaticNativeMath -> pair fee overrides multiplier -> FEE_300 @@ -1446,7 +1460,7 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFlagRules(rules); policy.setPairFee(key.currency0, key.currency1, FEE_200); - policy.setFamilyMultiplier(3, 5000); // 50% + policy.setFamilyMultiplier(3, 500_000); // 50% vm.stopPrank(); // FEE_200 (200|200) * 50% = (100|100) = FEE_100 From 455415217b50166c7feac5a3104d9a55840da51c Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 5 May 2026 11:41:30 -0400 Subject: [PATCH 05/26] docs: document V4 adapter design in README Replaces the "TBD" V4 placeholder with a description of the adapter+policy split, the StaticNativeMath/Classified path branching, the LP-fee multiplier, family-based classification, and the permissioned roles. Updates the project structure block to include V4FeePolicy.sol, V3OpenFeeAdapter.sol, and V4FeeAdapter.fork.t.sol, and removes Uniswap v4 from "future development" since it's no longer future. --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6983d378..cb36295c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Uniswap Fee Collection -_A unified system for collecting and converting fees from arbitrary revenue sources on arbitrary chains._ +*A unified system for collecting and converting fees from arbitrary revenue sources on arbitrary chains.* ## Table of Contents @@ -110,10 +110,46 @@ Fee Sources are adapter contracts that channel fees from various protocols into - Permissionless protocol fee collection - Configurable fee rates per fee tier -**Uniswap V4 (TBD)** +**Uniswap V4** -- V4FeeAdapter as ProtocolFeeAdapter -- Not included as part of the initial fee enablement +- `V4FeeAdapter` registered with the PoolManager as `protocolFeeController`; permissionless `triggerFeeUpdate` and `collect` calls push resolved fees to the PoolManager and route accrued revenue to the TokenJar +- Fee resolution is split between a thin, long-lived adapter and a replaceable `V4FeePolicy`, so governance can iterate on fee strategy without re-handing PoolManager privileges +- LP-fee-proportional protocol fee for vanilla pools; family-based classification for hook-using and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution)) + +#### V4 Fee Resolution + +The adapter checks for a per-pool override first, then delegates to the policy: + +``` +adapter.poolOverrides[poolId] ──► return (sentinel-decoded) + │ + └──► policy.computeFee(key) + │ + ├── Path A: StaticNativeMath ──► pairFees[pair] OR + │ key.fee × protocolFeeMultiplierPips / 1_000_000 + │ + └── Path B: Classified ────────► familyId resolved from + hookFamilyId[hook] OR hook-reported flags + │ + └─► pairFee × familyMultiplierPips OR + familyDefaults[family] OR + defaultFee +``` + +A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA` flags *and* the LP fee is static (`key.fee != 0x800000`), and **Path B (Classified)** otherwise (custom-accounting hook *or* dynamic-fee pool). `key.fee` is unreliable on Path B because dynamic fees can change every swap, and custom-accounting hooks can rewrite swap deltas, so the LP-fee multiplier doesn't apply there. + +Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) and one cap (`MultiplierTooLarge` reverts on values above 100%). The protocol fee per direction is also bounded by v4-core's `MAX_PROTOCOL_FEE = 1000` pips. + +`familyId` resolution for Path B: + +1. `hookFamilyId[hook]` (governance override) — wins if non-zero +2. gas-capped staticcall to `hook.protocolFeeFlags()` (optional `IFeeClassifiedHook` interface) → walk governance-configured `flagRules` first-match-wins +3. otherwise unclassified → falls through to `defaultFee` + +Permissioned roles: + +- **owner** — swaps the policy, sets the fee-setter +- **feeSetter** — configures pool overrides, pair fees, hook families, flag rules, family defaults/multipliers, the global multiplier, and `defaultFee` ### 3. Releasers @@ -403,8 +439,10 @@ src/ │ ├── Nonce.sol // Utility contract to safely sequence multiple pending transactions │ └── ResourceManager.sol. // Utility contract for defining the `RESOURCE` token and its amount requirements ├── feeAdapters -│ ├── V3FeeAdapter.sol // Logic for Uniswap v3 fee-setting and collection -│ └── V4FeeAdapter.sol // Work-in-progress logic for Uniswap v4 fee-setting and collection +│ ├── V3FeeAdapter.sol // Logic for Uniswap v3 fee-setting and collection +│ ├── V3OpenFeeAdapter.sol // Permissionless v3 adapter for non-mainnet chains +│ ├── V4FeeAdapter.sol // V4 protocolFeeController: pool overrides + policy delegation +│ └── V4FeePolicy.sol // V4 fee resolution: LP-fee multiplier + family-based classification ├── interfaces/ // interfaces ├── libraries │ ├── ArrayLib.sol // Utility library @@ -422,6 +460,7 @@ test ├── ProtocolFees.fork.t.sol // Fork tests against Ethereum Mainnet, using Deployer.sol ├── V3FeeAdapter.t.sol ├── V4FeeAdapter.t.sol +├── V4FeeAdapter.fork.t.sol // V4 integration tests against a real PoolManager ├── interfaces/ // interfaces for integrations ├── mocks/ // mocks and examples └── utils @@ -456,7 +495,6 @@ Advanced mechanism design for optimizing fee collection efficiency through aucti ### Additional Protocol Support -- Uniswap v4 - UniswapX fee integration - Interface fee collection - Third-party protocol adapters From c64860a5f157eb2bef7b322f31c858a256ca3180 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 5 May 2026 16:52:54 -0400 Subject: [PATCH 06/26] refactor(v4): replace single multiplier with piecewise-linear fee buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StaticNativeMath path now resolves protocol fees via an ascending-by-lpFeeFloor array of FeeBucket{lpFeeFloor, alphaPips, betaPips} (max 16). For a given key.fee, the policy walks the array backward to find the largest floor <= key.fee (snap to bucket 0 if key.fee < floor_0) and returns alpha + beta * (key.fee - floor) / 1_000_000 per direction, clamped to MAX_PROTOCOL_FEE and packed symmetrically. The pair-fee override still wins over the bucket calculation. Setting beta = 0 yields a step function; alpha = 0 yields a slope-only multiplier; both nonzero yields a piecewise-linear curve. Continuity at boundaries is governance's responsibility — the contract does not enforce it. The lowest bucket's alpha doubles as a minimum-fee floor for very-low-LP-fee pools, since key.fee < floor_0 evaluates as delta = 0 against bucket 0. Per-bucket validation: alpha <= MAX_PROTOCOL_FEE = 1000 (direct check, since alpha is a single per-direction value rather than a packed two- component fee), beta <= 1_000_000_000 (above which the per-direction clamp always saturates). Reuses MultiplierTooLarge for beta and adds EmptyBuckets / BucketsNotAscending / TooManyBuckets errors. --- README.md | 15 +- snapshots/V4FeeAdapterForkTest.json | 4 +- snapshots/V4FeeAdapterTest.json | 21 +- src/feeAdapters/V4FeePolicy.sol | 125 +++++++-- src/interfaces/IV4FeePolicy.sol | 96 +++++-- test/V4FeeAdapter.fork.t.sol | 34 ++- test/V4FeeAdapter.t.sol | 392 ++++++++++++++++++++++------ 7 files changed, 516 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index cb36295c..f3fb7835 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Fee Sources are adapter contracts that channel fees from various protocols into - `V4FeeAdapter` registered with the PoolManager as `protocolFeeController`; permissionless `triggerFeeUpdate` and `collect` calls push resolved fees to the PoolManager and route accrued revenue to the TokenJar - Fee resolution is split between a thin, long-lived adapter and a replaceable `V4FeePolicy`, so governance can iterate on fee strategy without re-handing PoolManager privileges -- LP-fee-proportional protocol fee for vanilla pools; family-based classification for hook-using and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution)) +- Piecewise-linear protocol fee schedule (per-bucket `alpha + beta × delta` over LP fee) for vanilla pools; family-based classification for hook-using and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution)) #### V4 Fee Resolution @@ -126,7 +126,10 @@ adapter.poolOverrides[poolId] ──► return (sentinel-decoded) └──► policy.computeFee(key) │ ├── Path A: StaticNativeMath ──► pairFees[pair] OR - │ key.fee × protocolFeeMultiplierPips / 1_000_000 + │ walk feeBuckets backward, find largest + │ lpFeeFloor <= key.fee (snap to bucket 0 + │ if key.fee < floor_0); return + │ alpha + beta × (key.fee - floor) / 1_000_000 │ └── Path B: Classified ────────► familyId resolved from hookFamilyId[hook] OR hook-reported flags @@ -136,9 +139,11 @@ adapter.poolOverrides[poolId] ──► return (sentinel-decoded) defaultFee ``` -A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA` flags *and* the LP fee is static (`key.fee != 0x800000`), and **Path B (Classified)** otherwise (custom-accounting hook *or* dynamic-fee pool). `key.fee` is unreliable on Path B because dynamic fees can change every swap, and custom-accounting hooks can rewrite swap deltas, so the LP-fee multiplier doesn't apply there. +A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA` flags *and* the LP fee is static (`key.fee != 0x800000`), and **Path B (Classified)** otherwise (custom-accounting hook *or* dynamic-fee pool). `key.fee` is unreliable on Path B because dynamic fees can change every swap, and custom-accounting hooks can rewrite swap deltas, so the bucket schedule doesn't apply there. -Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) and one cap (`MultiplierTooLarge` reverts on values above 100%). The protocol fee per direction is also bounded by v4-core's `MAX_PROTOCOL_FEE = 1000` pips. +Path A's fee buckets are an ascending-by-`lpFeeFloor` array (max 16) of `(lpFeeFloor, alpha, beta)` triples. Each bucket's `alpha` is a flat per-direction base fee (≤ `MAX_PROTOCOL_FEE = 1000`), and `beta` is a slope in pips per pip of `(lpFee - floor)` (≤ 1_000_000_000). Setting `beta = 0` yields a pure step function; `alpha = 0` yields a slope-only multiplier; both nonzero yields a piecewise-linear curve. Continuity at boundaries is governance's responsibility — the contract does not enforce it. The lowest bucket's `alpha` doubles as a minimum-fee floor for very-low-LP-fee pools, since `key.fee < floor_0` snaps to bucket 0 with `delta = 0`. + +Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`). Family multipliers on Path B are capped at 100%; bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts setters that exceed their respective caps. `familyId` resolution for Path B: @@ -149,7 +154,7 @@ Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1 Permissioned roles: - **owner** — swaps the policy, sets the fee-setter -- **feeSetter** — configures pool overrides, pair fees, hook families, flag rules, family defaults/multipliers, the global multiplier, and `defaultFee` +- **feeSetter** — configures pool overrides, pair fees, hook families, flag rules, family defaults/multipliers, the fee buckets, and `defaultFee` ### 3. Releasers diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 6428d75b..7c101a7c 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "91339", + "fork: batchTriggerFeeUpdate 3 pools": "97188", "fork: collect 2 currencies": "99567", "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "53099" + "fork: triggerFeeUpdate single pool": "56382" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 3f7a632f..2c36a296 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,9 +1,9 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "72395", + "adapter.batchTriggerFeeUpdate - two pools": "76961", "adapter.collect - single currency": "58075", "adapter.getFee - pool override hit": "3329", "adapter.setPoolOverride": "48080", - "adapter.triggerFeeUpdate - single pool": "53039", + "adapter.triggerFeeUpdate - single pool": "56322", "policy.computeFee - classified family default": "10090", "policy.computeFee - classified flag-rule self-report": "19991", "policy.computeFee - classified griefing hook -> defaultFee": "7721", @@ -11,13 +11,14 @@ "policy.computeFee - classified unclassified -> defaultFee": "7721", "policy.computeFee - flag-rule multi-flag match": "19991", "policy.computeFee - flag-rule single flag match": "19991", - "policy.computeFee - static native math multiplier": "6024", - "policy.computeFee - static native math pair fee": "3583", - "policy.setFamilyDefault": "47837", - "policy.setFamilyMultiplier": "47662", - "policy.setFlagRules - 32 rules (max)": "1493895", - "policy.setFlagRules - two rules": "137715", + "policy.computeFee - static native math buckets": "9307", + "policy.computeFee - static native math pair fee": "3577", + "policy.setFamilyDefault": "47836", + "policy.setFamilyMultiplier": "47683", + "policy.setFeeBuckets - 1 bucket": "71383", + "policy.setFeeBuckets - 5 buckets (configuration B)": "148215", + "policy.setFlagRules - 32 rules (max)": "1493907", + "policy.setFlagRules - two rules": "137727", "policy.setHookFamily": "47567", - "policy.setPairFee": "48697", - "policy.setProtocolFeeMultiplier": "46940" + "policy.setPairFee": "48696" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 7b815ada..38fc0f1b 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -8,16 +8,19 @@ import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; -import {IV4FeePolicy, FlagRule} from "../interfaces/IV4FeePolicy.sol"; +import {IV4FeePolicy, FlagRule, FeeBucket} from "../interfaces/IV4FeePolicy.sol"; import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @title V4FeePolicy /// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification -/// and a global pips multiplier on `key.fee`. +/// and a piecewise-linear schedule of fee buckets. /// @dev Pools are classified into two paths: -/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → pair fee, else -/// `key.fee × protocolFeeMultiplierPips / 1_000_000` per direction (clamped to -/// MAX_PROTOCOL_FEE). +/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → pair fee, else evaluate +/// the fee-bucket schedule: find the bucket with the largest `lpFeeFloor <= key.fee` +/// (snap to bucket 0 if `key.fee < floor_0`) and return +/// `alpha + beta * (key.fee - floor) / 1_000_000` per direction (clamped to +/// MAX_PROTOCOL_FEE, packed symmetrically). The lowest bucket's `alpha` doubles as a +/// minimum-fee floor for very-low-LP-fee pools. /// - Classified: custom accounting or dynamic fee → family multiplier × pair fee, family /// default, or global default fee. /// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). @@ -39,9 +42,20 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; - /// @dev Denominator for protocolFeeMultiplierPips. 1_000_000 = 100% (matches MAX_LP_FEE). + /// @dev Shared denominator for pips-based multipliers. 1_000_000 = 100% (matches + /// MAX_LP_FEE). Used by the StaticNativeMath bucket schedule and by family multipliers + /// on the classified path. uint24 internal constant MULTIPLIER_DENOMINATOR = 1_000_000; + /// @dev Maximum number of fee buckets. Bounds the backward walk in + /// `_computeStaticNativeMathFee`. + uint256 internal constant MAX_BUCKETS = 16; + + /// @dev Maximum `betaPips` per bucket. Above this, even a 1-pip delta exceeds + /// MAX_PROTOCOL_FEE (1000), so the per-direction clamp is always hit and values above + /// are functionally identical noise. + uint32 internal constant MAX_BETA_PIPS = 1_000_000_000; + /// @inheritdoc IV4FeePolicy IPoolManager public immutable POOL_MANAGER; @@ -63,8 +77,10 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy mapping(bytes32 pairHash => uint24) public pairFees; - /// @inheritdoc IV4FeePolicy - uint24 public protocolFeeMultiplierPips; + /// @dev Ordered fee buckets for the StaticNativeMath path. Ascending by + /// `lpFeeFloor`. Set atomically via `setFeeBuckets`. Empty array → Path A returns 0 + /// (when no pair-fee override exists). + FeeBucket[] internal _feeBuckets; /// @dev Maximum number of flag rules to bound gas in _resolveFamily. uint256 internal constant MAX_FLAG_RULES = 32; @@ -103,7 +119,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) { uint24 stored = pairFees[ph]; if (stored != 0) return _decodeFee(stored); - return _applyLpFeeMultiplier(key.fee, protocolFeeMultiplierPips); + return _computeStaticNativeMathFee(key.fee); } // Classified: custom accounting OR dynamic fee @@ -141,6 +157,23 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return (rule.requiredFlags, rule.familyId); } + // ─── Fee Bucket Getters ─── + + /// @inheritdoc IV4FeePolicy + function feeBucketsLength() external view returns (uint256) { + return _feeBuckets.length; + } + + /// @inheritdoc IV4FeePolicy + function feeBucket(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 alphaPips, uint32 betaPips) + { + FeeBucket storage b = _feeBuckets[index]; + return (b.lpFeeFloor, b.alphaPips, b.betaPips); + } + // ─── Admin ─── /// @inheritdoc IV4FeePolicy @@ -192,10 +225,33 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function setProtocolFeeMultiplier(uint24 pips) external onlyFeeSetter { - if (pips > MULTIPLIER_DENOMINATOR) revert MultiplierTooLarge(); - protocolFeeMultiplierPips = pips; - emit ProtocolFeeMultiplierUpdated(pips); + function setFeeBuckets(FeeBucket[] calldata buckets) external onlyFeeSetter { + uint256 len = buckets.length; + if (len == 0) revert EmptyBuckets(); + if (len > MAX_BUCKETS) revert TooManyBuckets(); + + delete _feeBuckets; + + uint24 prevFloor; + for (uint256 i; i < len; ++i) { + FeeBucket calldata b = buckets[i]; + if (i > 0 && b.lpFeeFloor <= prevFloor) revert BucketsNotAscending(); + // alpha is a single per-direction value, so check directly against + // MAX_PROTOCOL_FEE rather than reusing _validateFee (which expects a packed + // two-component fee value). + if (b.alphaPips > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) revert InvalidFeeValue(); + if (b.betaPips > MAX_BETA_PIPS) revert MultiplierTooLarge(); + _feeBuckets.push(b); + prevFloor = b.lpFeeFloor; + } + + emit FeeBucketsUpdated(len); + } + + /// @inheritdoc IV4FeePolicy + function clearFeeBuckets() external onlyFeeSetter { + delete _feeBuckets; + emit FeeBucketsUpdated(0); } /// @inheritdoc IV4FeePolicy @@ -300,21 +356,32 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return keccak256(abi.encodePacked(Currency.unwrap(c0), Currency.unwrap(c1))); } - /// @dev Applies the global pips multiplier to an LP fee, packing the result symmetrically - /// into both 12-bit directional components and clamping each to MAX_PROTOCOL_FEE (1000). - /// Shares MULTIPLIER_DENOMINATOR (1_000_000) with `_applyMultiplier`. Distinct from that - /// helper because this one constructs a symmetric packed fee from a single LP fee value - /// and must clamp (LP fees can exceed MAX_PROTOCOL_FEE), whereas `_applyMultiplier` - /// rescales an already-validated packed protocol fee per direction and needs no clamp. - /// @param lpFee The pool's LP fee in pips (from key.fee for static fee pools). - /// @param multiplierPips The multiplier in pips (max MULTIPLIER_DENOMINATOR = 1_000_000). + /// @dev Walks `_feeBuckets` backward to find the largest floor `<= lpFee`, evaluates + /// the piecewise-linear formula `alpha + beta * (lpFee - floor) / MULTIPLIER_DENOMINATOR` + /// per direction, clamps to MAX_PROTOCOL_FEE, and packs symmetrically into both 12-bit + /// components. Snap behavior: when `lpFee < floor_0`, the loop never breaks and the + /// pre-loop default of `_feeBuckets[0]` applies, with `delta = 0`, so the result is + /// `alpha_0` — the de facto minimum fee for very-low-LP-fee pools. + /// @param lpFee The pool's LP fee in pips (from key.fee). /// @return The packed protocol fee with both 12-bit components equal. - function _applyLpFeeMultiplier(uint24 lpFee, uint24 multiplierPips) - internal - pure - returns (uint24) - { - uint256 perDirection = uint256(lpFee) * multiplierPips / MULTIPLIER_DENOMINATOR; + function _computeStaticNativeMathFee(uint24 lpFee) internal view returns (uint24) { + uint256 len = _feeBuckets.length; + if (len == 0) return 0; + + // Default to the lowest bucket so the snap case (lpFee < floor_0) falls out + // naturally below with delta = 0. + FeeBucket memory bucket = _feeBuckets[0]; + for (uint256 i = len; i > 0; --i) { + FeeBucket memory candidate = _feeBuckets[i - 1]; + if (candidate.lpFeeFloor <= lpFee) { + bucket = candidate; + break; + } + } + + uint256 delta = lpFee >= bucket.lpFeeFloor ? lpFee - bucket.lpFeeFloor : 0; + uint256 perDirection = + uint256(bucket.alphaPips) + uint256(bucket.betaPips) * delta / MULTIPLIER_DENOMINATOR; if (perDirection > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) { perDirection = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; } @@ -323,8 +390,8 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @dev Scales each 12-bit directional fee component by a pips multiplier. The two /// 12-bit components are extracted, scaled independently, and repacked into a single - /// uint24. Shares MULTIPLIER_DENOMINATOR with `_applyLpFeeMultiplier`. No clamp is - /// needed: pairFees are validated <= MAX_PROTOCOL_FEE per direction at write time, + /// uint24. Shares MULTIPLIER_DENOMINATOR with `_computeStaticNativeMathFee`. No clamp + /// is needed: pairFees are validated <= MAX_PROTOCOL_FEE per direction at write time, /// and `multiplierPips` is bounded by `setFamilyMultiplier` to <= 1_000_000. /// @param baseFee The base protocol fee (two 12-bit directional components packed). /// @param multiplierPips The multiplier in pips (max 1_000_000 = 100%). diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 625ad827..eb66961c 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -5,6 +5,22 @@ import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; +/// @dev A single segment of the piecewise-linear protocol-fee schedule on the +/// StaticNativeMath path. Buckets are stored in an ascending-by-lpFeeFloor array. +/// For a given `key.fee`, evaluation finds the bucket with the largest floor +/// <= key.fee (or bucket 0 if key.fee < floor_0) and returns +/// alpha + beta * (key.fee - floor) / 1_000_000 per direction (clamped to +/// MAX_PROTOCOL_FEE). +struct FeeBucket { + /// @dev LP-fee floor for this bucket, in pips. Ascending across the array; matches v4-core fee type. + uint24 lpFeeFloor; + /// @dev Flat base fee per direction in pips. Must be <= MAX_PROTOCOL_FEE (1000). + uint24 alphaPips; + /// @dev Slope: pips of protocol fee per pip of (lpFee - floor). Capped to 1_000_000_000 + /// (above which any 1-pip delta saturates the per-direction MAX_PROTOCOL_FEE clamp). + uint32 betaPips; +} + /// @dev A flag-to-family mapping rule. The policy walks rules in order; the first rule /// whose requiredFlags are all present in the hook's self-reported flags wins. struct FlagRule { @@ -22,8 +38,9 @@ struct FlagRule { /// Family IDs have no hardcoded semantic meaning — labels live in offchain documentation. /// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); /// governance-configured flag rules map flag patterns to families automatically. -/// Static NativeMath pools bypass classification and derive their protocol fee as a -/// fixed pips multiplier of `key.fee` (per-direction, clamped to MAX_PROTOCOL_FEE). +/// Static NativeMath pools bypass classification and derive their protocol fee from a +/// piecewise-linear schedule of fee buckets keyed by LP-fee floor (per-direction, +/// clamped to MAX_PROTOCOL_FEE). /// Custom-accounting hooks and dynamic fee pools require classification (governance /// override, flag rule match, or defaultFee fallback). /// @custom:security-contact security@uniswap.org @@ -39,7 +56,8 @@ interface IV4FeePolicy { /// @notice Thrown when familyId == 0 is passed to a function that requires > 0. error InvalidFamilyId(); - /// @notice Thrown when setProtocolFeeMultiplier is called with pips > 1_000_000. + /// @notice Thrown when a multiplier exceeds its allowed bound: `familyMultiplierPips` + /// > 1_000_000, or a fee bucket's `betaPips` > 1_000_000_000. error MultiplierTooLarge(); /// @notice Thrown when currency0 >= currency1 in setPairFee. @@ -51,6 +69,15 @@ interface IV4FeePolicy { /// @notice Thrown when flag rules exceed the maximum allowed count. error TooManyFlagRules(); + /// @notice Thrown when setFeeBuckets is called with an empty array. + error EmptyBuckets(); + + /// @notice Thrown when fee buckets are not in strictly ascending order of lpFeeFloor. + error BucketsNotAscending(); + + /// @notice Thrown when fee buckets exceed the maximum allowed count. + error TooManyBuckets(); + // --- Events --- /// @notice Emitted when the fee setter address is updated. @@ -78,9 +105,9 @@ interface IV4FeePolicy { /// @param feeValue The new pair fee (0 = removed). event PairFeeUpdated(bytes32 indexed pairHash, uint24 feeValue); - /// @notice Emitted when the global protocol fee multiplier is updated. - /// @param multiplierPips The new multiplier in pips (1_000_000 = 100% of LP fee). - event ProtocolFeeMultiplierUpdated(uint24 multiplierPips); + /// @notice Emitted when the fee buckets array is replaced. + /// @param bucketCount The number of buckets in the new array. + event FeeBucketsUpdated(uint256 bucketCount); /// @notice Emitted when the default classified fee is updated. /// @param feeValue The new default fee (0 = removed). @@ -129,24 +156,31 @@ interface IV4FeePolicy { /// @notice Returns the multiplier (in pips) for a given family ID. /// @dev 1_000_000 = 100% (1x), 500_000 = 50% (0.5x). Applied to pairFees to derive a /// scaled fee on the classified path. Shares the same denominator - /// (MULTIPLIER_DENOMINATOR = 1_000_000) as protocolFeeMultiplierPips. + /// (MULTIPLIER_DENOMINATOR = 1_000_000) as the StaticNativeMath bucket schedule. /// @param familyId The family to query. /// @return The multiplier in pips (0 = not set). function familyMultiplierPips(uint8 familyId) external view returns (uint24); /// @notice Returns the pair fee for a token pair hash. /// @dev Flat mapping — one fee per pair. StaticNativeMath uses it directly (overrides - /// the multiplier). Classified pools scale it by the family multiplier. + /// the bucket schedule). Classified pools scale it by the family multiplier. /// @param pairHash The canonical keccak256 hash of the sorted token pair. /// @return The sentinel-encoded pair fee (0 = not set). function pairFees(bytes32 pairHash) external view returns (uint24); - /// @notice Returns the global multiplier (in pips) applied to `key.fee` on the - /// StaticNativeMath path when no pair fee is set. - /// @dev Pips, where 1_000_000 = 100% of the LP fee. 0 disables the protocol fee on - /// this path. Per-direction result is clamped to MAX_PROTOCOL_FEE. - /// @return The current multiplier in pips. - function protocolFeeMultiplierPips() external view returns (uint24); + /// @notice Returns the number of fee buckets configured. + /// @return The count of fee buckets. + function feeBucketsLength() external view returns (uint256); + + /// @notice Returns the fee bucket at the given index. + /// @param index The zero-based index into the buckets array. + /// @return lpFeeFloor The LP-fee floor for this bucket. + /// @return alphaPips The flat base fee per direction in pips. + /// @return betaPips The slope: pips of protocol fee per pip of (lpFee - floor). + function feeBucket(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 alphaPips, uint32 betaPips); /// @notice Returns the number of flag rules configured. /// @return The count of flag rules. @@ -173,9 +207,11 @@ interface IV4FeePolicy { /// @notice Computes the protocol fee for a pool. /// @dev Three paths: - /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee, or - /// `key.fee × protocolFeeMultiplierPips / 1_000_000` (per direction, clamped to - /// MAX_PROTOCOL_FEE). + /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee, or evaluate the + /// fee-bucket schedule — find the bucket with the largest `lpFeeFloor <= key.fee` + /// (snap to bucket 0 if `key.fee < floor_0`) and return + /// `alpha + beta * (key.fee - floor) / 1_000_000` per direction (clamped to + /// MAX_PROTOCOL_FEE, packed symmetrically). /// 2. Dynamic fee NativeMath: requires governance familyId (Slot0.lpFee is unreliable). /// 3. CustomAccounting (return-delta flags set): requires governance familyId. /// Paths 2 and 3 fall through to defaultFee if unclassified. @@ -220,15 +256,21 @@ interface IV4FeePolicy { /// @notice Removes the default fee, so unclassified pools return 0. function clearDefaultFee() external; - // --- Multiplier Configuration (onlyFeeSetter) --- + // --- Fee Bucket Configuration (onlyFeeSetter) --- + + /// @notice Replaces the entire fee-buckets array atomically. + /// @dev Must be non-empty, ascending by `lpFeeFloor` (strict), and at most 16 buckets. + /// Each bucket: `alphaPips <= MAX_PROTOCOL_FEE` (1000), `betaPips <= 1_000_000_000`. + /// The lowest bucket's `alpha` acts as a minimum-fee floor for very-low-LP-fee pools + /// because of the snap-to-lowest behavior in `computeFee`. + /// Reverts: `EmptyBuckets`, `TooManyBuckets`, `BucketsNotAscending`, + /// `InvalidFeeValue` (alpha out of range), `MultiplierTooLarge` (beta out of range). + /// @param buckets The new fee buckets, ordered ascending by lpFeeFloor. + function setFeeBuckets(FeeBucket[] calldata buckets) external; - /// @notice Sets the global multiplier applied to `key.fee` on the StaticNativeMath - /// path when no pair fee is set. - /// @dev Reverts MultiplierTooLarge if `pips > 1_000_000`. Setting 0 disables the - /// protocol fee on this path (no separate clear function needed — the resulting fee - /// computes to 0 directly). - /// @param pips The multiplier in pips (max 1_000_000 = 100% of LP fee). - function setProtocolFeeMultiplier(uint24 pips) external; + /// @notice Removes all fee buckets. The StaticNativeMath path then returns 0 for any + /// pool that has no pair-fee override. + function clearFeeBuckets() external; // --- Family Defaults & Multipliers (onlyFeeSetter) --- @@ -257,7 +299,7 @@ interface IV4FeePolicy { // --- Pair Fees (onlyFeeSetter) --- /// @notice Sets the pair fee for a token pair. - /// @dev StaticNativeMath pools use this directly (overrides the multiplier). + /// @dev StaticNativeMath pools use this directly (overrides the fee buckets). /// Classified pools scale it by familyMultiplierPips. Setting 0 sets explicit zero. /// Use clearPairFee to remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). @@ -265,7 +307,7 @@ interface IV4FeePolicy { /// @param feeValue The pair fee. Must pass isValidProtocolFee if non-zero. function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) external; - /// @notice Removes the pair fee, falling through to the multiplier. + /// @notice Removes the pair fee, falling through to the fee buckets. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. function clearPairFee(Currency currency0, Currency currency1) external; diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol index 6e078bc7..e55a86b7 100644 --- a/test/V4FeeAdapter.fork.t.sol +++ b/test/V4FeeAdapter.fork.t.sol @@ -18,6 +18,7 @@ import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; +import {FeeBucket} from "../src/interfaces/IV4FeePolicy.sol"; /// @notice Integration tests using a real v4 PoolManager (deployed locally via Deployers). /// Verifies protocol fee accrual from real swaps, collection to TokenJar, and the full @@ -88,12 +89,19 @@ contract V4FeeAdapterForkTest is Deployers { ); } + /// @dev Equivalent of the pre-bucket-era global multiplier: a single bucket starting + /// at floor 0 with `betaPips = X`. Result: `protocolFee = X * lpFee / 1_000_000`. + function _singleBucketSlope(uint32 betaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: betaPips}); + } + // ============ End-to-End: Set Fee -> Swap -> Accrue -> Collect ============ function test_e2e_setFee_swap_collect() public { // 66_667 pips × pool3000.fee (3000) / 1_000_000 = 200 per direction (= PROTO_FEE_200) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(66_667); + policy.setFeeBuckets(_singleBucketSlope(66_667)); // Trigger fee update on the 3000 bps pool adapter.triggerFeeUpdate(pool3000); @@ -133,13 +141,13 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Multiplier: Pools Scale Linearly With LP Fee ============ - function test_multiplier_differentPoolsLinearlyScaled() public { + function test_buckets_differentPoolsLinearlyScaled() public { // multiplier = 100_000 (10% of LP fee) // pool500 (LP 500) -> 50 per direction // pool3000 (LP 3000) -> 300 per direction (= PROTO_FEE_300) // pool10000 (LP 10_000) -> 1000 per direction, clamped to MAX_PROTOCOL_FEE vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); PoolKey[] memory keys = new PoolKey[](3); keys[0] = pool500; @@ -165,7 +173,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_poolOverride_bypassesPolicy() public { // 200_000 pips × pool500.fee (500) / 1_000_000 = 100 per direction (= PROTO_FEE_100) vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(200_000); + policy.setFeeBuckets(_singleBucketSlope(200_000)); // Override one pool to PROTO_FEE_500 adapter.setPoolOverride(pool3000.toId(), PROTO_FEE_500); @@ -189,7 +197,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_pairFee_overridesMultiplier() public { vm.startPrank(feeSetter); // Any non-zero multiplier — pair fee should win regardless - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); policy.setPairFee(currency0, currency1, PROTO_FEE_300); vm.stopPrank(); @@ -205,7 +213,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_feesAccrueFromMultipleSwaps() public { // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); adapter.triggerFeeUpdate(pool3000); @@ -234,10 +242,10 @@ contract V4FeeAdapterForkTest is Deployers { // ============ Fee Update After Multiplier Change ============ - function test_multiplierChange_requiresRetrigger() public { + function test_bucketsChange_requiresRetrigger() public { // 33_334 pips × 3000 / 1_000_000 = 100 per direction (= PROTO_FEE_100, integer-truncated) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(33_334); + policy.setFeeBuckets(_singleBucketSlope(33_334)); adapter.triggerFeeUpdate(pool3000); (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); @@ -245,7 +253,7 @@ contract V4FeeAdapterForkTest is Deployers { // 166_667 pips × 3000 / 1_000_000 = 500 per direction (= PROTO_FEE_500, integer-truncated) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(166_667); + policy.setFeeBuckets(_singleBucketSlope(166_667)); // Pool still has old fee until retriggered (,, uint24 feeStale,) = manager.getSlot0(pool3000.toId()); @@ -262,7 +270,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_policySwap_newPolicyTakesEffect() public { // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); adapter.triggerFeeUpdate(pool3000); (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); @@ -283,7 +291,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_explicitZeroOverride_preventsFeeAccrual() public { // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); // Override pool to explicit zero adapter.setPoolOverride(pool3000.toId(), 0); @@ -306,7 +314,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_clearOverride_restoresPolicy() public { // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); adapter.setPoolOverride(pool3000.toId(), 0); // explicit zero vm.stopPrank(); @@ -332,7 +340,7 @@ contract V4FeeAdapterForkTest is Deployers { function test_partialCollection() public { // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); + policy.setFeeBuckets(_singleBucketSlope(100_000)); adapter.triggerFeeUpdate(pool3000); // Swap to accrue fees diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 2f816e07..6b63f8d2 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -13,7 +13,7 @@ import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; -import {FlagRule} from "../src/interfaces/IV4FeePolicy.sol"; +import {FlagRule, FeeBucket} from "../src/interfaces/IV4FeePolicy.sol"; import {HookFeeFlags} from "../src/libraries/HookFeeFlags.sol"; import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { @@ -111,13 +111,46 @@ contract V4FeeAdapterTest is Test { // ============ Helpers ============ - /// @dev Multiplier value chosen so that `standardKey.fee = 3000` yields - /// `FEE_300` (300 pips per direction): 3000 × 100_000 / 1_000_000 = 300. - uint24 internal constant TEST_MULTIPLIER_PIPS = 100_000; + /// @dev Slope value chosen so that `standardKey.fee = 3000` yields `FEE_300` + /// (300 pips per direction) under a single bucket starting at floor 0: + /// `0 + 100_000 × 3000 / 1_000_000 = 300`. + uint32 internal constant TEST_BETA_PIPS = 100_000; - function _setMultiplier(uint24 pips) internal { + /// @dev Returns a single-bucket array `[(0, 0, betaPips)]`. Equivalent in math to a + /// pre-bucket-era global multiplier: `protocolFee = 0 + betaPips * lpFee / 1_000_000`. + /// Caller is responsible for the prank. + function _singleBucketSlope(uint32 betaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: betaPips}); + } + + /// @dev Returns a single-bucket array `[(0, alphaPips, 0)]` — flat fee for any LP fee. + function _singleBucketFlat(uint24 alphaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: alphaPips, betaPips: 0}); + } + + /// @dev As above but pranks feeSetter for one-off setups. + function _setSingleBucketSlope(uint32 betaPips) internal { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(pips); + policy.setFeeBuckets(_singleBucketSlope(betaPips)); + } + + /// @dev Adjusted Configuration B from the design discussion. All alpha values stay at + /// or below MAX_PROTOCOL_FEE = 1000 (the per-bucket cap). Continuity-preserving: + /// each bucket starts at the previous bucket's endpoint. + /// bucket 0: [0, 100) flat 0 + /// bucket 1: [100, 500) slope 10% (alpha 0, beta 100_000) + /// bucket 2: [500, 3000) starts at 40, slope 20% (alpha 40, beta 200_000) + /// bucket 3: [3000, 10_000) starts at 540, slope 15% (alpha 540, beta 150_000) + /// bucket 4: [10_000, ∞) flat ceiling at MAX_PROTOCOL_FEE + function _bucketsConfigB() internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](5); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 100_000}); + bs[2] = FeeBucket({lpFeeFloor: 500, alphaPips: 40, betaPips: 200_000}); + bs[3] = FeeBucket({lpFeeFloor: 3000, alphaPips: 540, betaPips: 150_000}); + bs[4] = FeeBucket({lpFeeFloor: 10_000, alphaPips: 1000, betaPips: 0}); } function _pairHash() internal view returns (bytes32) { @@ -201,7 +234,7 @@ contract V4FeeAdapterTest is Test { // Configure policy to return FEE_300 vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); assertEq(adapter.getFee(standardKey), FEE_300); // Set pool override to explicit zero -should NOT fall through to policy @@ -217,7 +250,7 @@ contract V4FeeAdapterTest is Test { PoolId id = standardKey.toId(); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); // Set override then clear it vm.startPrank(feeSetter); @@ -255,7 +288,7 @@ contract V4FeeAdapterTest is Test { function test_poolOverride_takesPriorityOverPolicy() public { // Configure policy to return FEE_300 via the multiplier path vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); // Set pool override to FEE_500 adapter.setPoolOverride(standardKey.toId(), FEE_500); vm.stopPrank(); @@ -268,7 +301,7 @@ contract V4FeeAdapterTest is Test { function test_triggerFeeUpdate_success() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); vm.expectEmit(true, true, false, true, address(adapter)); emit IV4FeeAdapter.FeeUpdateTriggered(alice, standardKey.toId(), FEE_300); @@ -295,7 +328,7 @@ contract V4FeeAdapterTest is Test { function testFuzz_triggerFeeUpdate_permissionless(address caller) public { vm.assume(caller != address(0)); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); vm.prank(caller); adapter.triggerFeeUpdate(standardKey); @@ -304,7 +337,7 @@ contract V4FeeAdapterTest is Test { function test_batchTriggerFeeUpdate_success() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); PoolKey[] memory keys = new PoolKey[](2); keys[0] = standardKey; @@ -404,173 +437,363 @@ contract V4FeeAdapterTest is Test { assertEq(policy.isCustomAccounting(address(addr)), expected); } - // ============ Policy: Protocol Fee Multiplier ============ + // ============ Policy: Fee Buckets ============ + + function test_setFeeBuckets_success() public { + FeeBucket[] memory bs = _singleBucketSlope(TEST_BETA_PIPS); - function test_setProtocolFeeMultiplier_success() public { vm.expectEmit(false, false, false, true, address(policy)); - emit IV4FeePolicy.ProtocolFeeMultiplierUpdated(TEST_MULTIPLIER_PIPS); + emit IV4FeePolicy.FeeBucketsUpdated(1); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + vm.snapshotGasLastCall("policy.setFeeBuckets - 1 bucket"); + assertEq(policy.feeBucketsLength(), 1); + (uint24 floor, uint24 alpha, uint32 beta) = policy.feeBucket(0); + assertEq(floor, 0); + assertEq(alpha, 0); + assertEq(beta, TEST_BETA_PIPS); + } + + function test_setFeeBuckets_5buckets_configurationB() public { + FeeBucket[] memory bs = _bucketsConfigB(); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FeeBucketsUpdated(5); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + vm.snapshotGasLastCall("policy.setFeeBuckets - 5 buckets (configuration B)"); + assertEq(policy.feeBucketsLength(), 5); + } + + function test_setFeeBuckets_revertsEmpty() public { + FeeBucket[] memory bs = new FeeBucket[](0); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + vm.expectRevert(IV4FeePolicy.EmptyBuckets.selector); + policy.setFeeBuckets(bs); + } - vm.snapshotGasLastCall("policy.setProtocolFeeMultiplier"); - assertEq(policy.protocolFeeMultiplierPips(), TEST_MULTIPLIER_PIPS); + function test_setFeeBuckets_revertsNotAscending() public { + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 50, alphaPips: 0, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.BucketsNotAscending.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsEqualFloors() public { + // Strict ascending: equal floors must also revert. + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.BucketsNotAscending.selector); + policy.setFeeBuckets(bs); } - function test_setProtocolFeeMultiplier_revertsTooLarge() public { + function test_setFeeBuckets_revertsAlphaTooLarge() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 1001, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsBetaTooLarge() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000_001}); vm.prank(feeSetter); vm.expectRevert(IV4FeePolicy.MultiplierTooLarge.selector); - policy.setProtocolFeeMultiplier(1_000_001); + policy.setFeeBuckets(bs); } - function test_setProtocolFeeMultiplier_acceptsBoundary() public { + function test_setFeeBuckets_revertsTooManyBuckets() public { + FeeBucket[] memory bs = new FeeBucket[](17); + for (uint256 i; i < 17; ++i) { + bs[i] = FeeBucket({lpFeeFloor: uint24(i * 100), alphaPips: 0, betaPips: 0}); + } vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); - assertEq(policy.protocolFeeMultiplierPips(), 1_000_000); + vm.expectRevert(IV4FeePolicy.TooManyBuckets.selector); + policy.setFeeBuckets(bs); } - function test_setProtocolFeeMultiplier_acceptsZero() public { - vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); - policy.setProtocolFeeMultiplier(0); - vm.stopPrank(); - assertEq(policy.protocolFeeMultiplierPips(), 0); + function test_setFeeBuckets_acceptsAlphaBoundary() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 1000, betaPips: 0}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + (, uint24 alpha,) = policy.feeBucket(0); + assertEq(alpha, 1000); } - function test_setProtocolFeeMultiplier_revertsUnauthorized() public { + function test_setFeeBuckets_acceptsBetaBoundary() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000_000}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + (,, uint32 beta) = policy.feeBucket(0); + assertEq(beta, 1_000_000_000); + } + + function test_setFeeBuckets_acceptsMaxBuckets() public { + FeeBucket[] memory bs = new FeeBucket[](16); + for (uint256 i; i < 16; ++i) { + bs[i] = FeeBucket({lpFeeFloor: uint24(i * 100), alphaPips: 0, betaPips: 0}); + } + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + assertEq(policy.feeBucketsLength(), 16); + } + + function test_setFeeBuckets_revertsUnauthorized() public { vm.prank(alice); vm.expectRevert(IV4FeePolicy.Unauthorized.selector); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); } - function test_setProtocolFeeMultiplier_replacesExisting() public { + function test_setFeeBuckets_replacesExisting() public { vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); - assertEq(policy.protocolFeeMultiplierPips(), TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_bucketsConfigB()); + assertEq(policy.feeBucketsLength(), 5); - policy.setProtocolFeeMultiplier(200_000); - assertEq(policy.protocolFeeMultiplierPips(), 200_000); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + assertEq(policy.feeBucketsLength(), 1); vm.stopPrank(); } + function test_clearFeeBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + assertEq(policy.feeBucketsLength(), 1); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FeeBucketsUpdated(0); + policy.clearFeeBuckets(); + vm.stopPrank(); + + assertEq(policy.feeBucketsLength(), 0); + // With no buckets and no pair fee, computeFee on standardKey returns 0. + assertEq(policy.computeFee(standardKey), 0); + } + + function test_clearFeeBuckets_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.clearFeeBuckets(); + } + // ============ Policy: computeFee - static native math path ============ - function test_computeFee_staticNativeMath_multiplier() public { + function test_computeFee_staticNativeMath_singleBucketSlope() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); - // key.fee = 3000, multiplier 100_000 (10%) -> 3000 * 100_000 / 1_000_000 = 300 + // key.fee = 3000, single bucket (0, 0, 100_000) -> 0 + 100_000 * 3000 / 1_000_000 = 300 assertEq(policy.computeFee(standardKey), FEE_300); - vm.snapshotGasLastCall("policy.computeFee - static native math multiplier"); + vm.snapshotGasLastCall("policy.computeFee - static native math buckets"); } - function test_computeFee_staticNativeMath_multiplier_lowFee() public { + function test_computeFee_staticNativeMath_singleBucketSlope_lowFee() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); PoolKey memory lowFeeKey = standardKey; lowFeeKey.fee = 100; poolManager.mockInitialize(lowFeeKey); - // key.fee = 100, multiplier 100_000 -> 100 * 100_000 / 1_000_000 = 10 per direction + // key.fee = 100 -> 100 * 100_000 / 1_000_000 = 10 per direction uint24 expected = (10 << 12) | 10; assertEq(policy.computeFee(lowFeeKey), expected); } - function test_computeFee_staticNativeMath_pairFeeOverridesMultiplier() public { + function test_computeFee_staticNativeMath_pairFeeOverridesBuckets() public { vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); vm.stopPrank(); - // Pair fee should override the multiplier-derived fee + // Pair fee should override the bucket-derived fee assertEq(policy.computeFee(standardKey), FEE_500); vm.snapshotGasLastCall("policy.computeFee - static native math pair fee"); } - function test_computeFee_staticNativeMath_unsetMultiplierReturnsZero() public view { + function test_computeFee_staticNativeMath_zeroBucketsReturnsZero() public view { assertEq(policy.computeFee(standardKey), 0); } function test_computeFee_staticNativeMath_hookWithoutDeltaFlags() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); // hookKey has address with bit 7 set but bits 0-3 clear -> StaticNativeMath path assertEq(policy.computeFee(hookKey), FEE_300); } - // ============ Policy: computeFee multiplier math ============ + // ============ Policy: computeFee bucket math ============ - function test_computeFee_staticNativeMath_zeroMultiplier() public { + function test_computeFee_staticNativeMath_singleBucketFlat() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(0); - assertEq(policy.computeFee(standardKey), 0); + policy.setFeeBuckets(_singleBucketFlat(25)); + + // alpha=25, beta=0 → flat 25 pips for any LP fee + uint24 expected = (25 << 12) | 25; + assertEq(policy.computeFee(standardKey), expected); // key.fee = 3000 + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + // snap-to-lowest with delta = 0 → still alpha = 25 + assertEq(policy.computeFee(zeroFeeKey), expected); } - function test_computeFee_staticNativeMath_zeroLpFee() public { + function test_computeFee_staticNativeMath_singleBucket1to1() public { + // 1:1 boundary: beta = 1_000_000 means protocol fee == LP fee + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000}); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 1000; + poolManager.mockInitialize(k); + // 1000 * 1_000_000 / 1_000_000 = 1000 per direction (clamp boundary) + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_clamps() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 5000; + poolManager.mockInitialize(k); + // 5000 * 1 = 5000, clamped to MAX_PROTOCOL_FEE = 1000 per direction + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_zeroLpFee_alphaZero() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); PoolKey memory zeroFeeKey = standardKey; zeroFeeKey.fee = 0; poolManager.mockInitialize(zeroFeeKey); - - // 0 * any multiplier = 0; LP fee of 0 means no protocol fee regardless of multiplier + // alpha = 0 + beta * 0 / 1_000_000 = 0 assertEq(policy.computeFee(zeroFeeKey), 0); } - function test_computeFee_staticNativeMath_multiplier10pct() public { + function test_computeFee_staticNativeMath_zeroLpFee_alphaNonzero() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketFlat(25)); + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + uint24 expected = (25 << 12) | 25; + assertEq(policy.computeFee(zeroFeeKey), expected); + } + + function test_computeFee_staticNativeMath_snapToLowest() public { + // Two buckets where the lowest floor is > 0. lpFee < floor_0 must snap to bucket 0. + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 25, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 500, alphaPips: 50, betaPips: 0}); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(100_000); // 10% of LP fee + policy.setFeeBuckets(bs); PoolKey memory k = standardKey; - k.fee = 100; + k.fee = 50; // below floor_0 = 100 poolManager.mockInitialize(k); - // 100 * 100_000 / 1_000_000 = 10 per direction (matches user's example) - uint24 expected = (10 << 12) | 10; + // Snap to bucket 0 with delta = 0 -> alpha_0 = 25 + uint24 expected = (25 << 12) | 25; assertEq(policy.computeFee(k), expected); } - function test_computeFee_staticNativeMath_multiplier100pct() public { + function test_computeFee_staticNativeMath_continuousPiecewise() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); // 100% of LP fee - + policy.setFeeBuckets(_bucketsConfigB()); + + // Spot-check several lpFee values against the curve table: + // lpFee=50 bucket 0 (flat 0) -> 0 + // lpFee=100 bucket 1 starts (delta=0) -> 0 + // lpFee=500 bucket 2 starts (delta=0) -> 40 + // lpFee=3000 bucket 3 starts (delta=0) -> 540 + // lpFee=10000 bucket 4 starts (flat 1000) -> 1000 (clamp territory) + // lpFee=200 bucket 1, delta=100 -> 0 + 100_000*100/1e6 = 10 + // lpFee=1000 bucket 2, delta=500 -> 40 + 200_000*500/1e6 = 140 + // lpFee=5000 bucket 3, delta=2000 -> 540 + 150_000*2000/1e6 = 840 PoolKey memory k = standardKey; - k.fee = 1000; + + k.fee = 50; poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), 0); - // 1000 * 1_000_000 / 1_000_000 = 1000 per direction (clamp boundary, no truncation) + k.fee = 100; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), 0); + + k.fee = 500; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (40 << 12) | 40); + + k.fee = 3000; + assertEq(policy.computeFee(k), (540 << 12) | 540); + + k.fee = 10_000; + poolManager.mockInitialize(k); assertEq(policy.computeFee(k), FEE_1000); + + k.fee = 200; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (10 << 12) | 10); + + k.fee = 1000; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (140 << 12) | 140); + + k.fee = 5000; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (840 << 12) | 840); } - function test_computeFee_staticNativeMath_multiplierClamps() public { + function test_computeFee_staticNativeMath_discontinuousPiecewise() public { + // Intentional cliff: alpha jumps at the boundary + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 25, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 100, betaPips: 0}); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); + policy.setFeeBuckets(bs); PoolKey memory k = standardKey; - k.fee = 5000; + k.fee = 99; poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (25 << 12) | 25); - // 5000 * 1_000_000 / 1_000_000 = 5000, clamped to MAX_PROTOCOL_FEE = 1000 per direction - assertEq(policy.computeFee(k), FEE_1000); + k.fee = 100; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (100 << 12) | 100); } - function test_computeFee_staticNativeMath_pairFeeBeatsMultiplier() public { + function test_computeFee_staticNativeMath_pairFeeBeatsBuckets() public { vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); + policy.setFeeBuckets(_bucketsConfigB()); policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_200); vm.stopPrank(); - // Pair fee wins over multiplier-derived fee + // Pair fee wins over bucket-derived fee assertEq(policy.computeFee(standardKey), FEE_200); } - function test_computeFee_dynamicFee_skipsMultiplier() public { - // Regression: a future routing bug must not multiply DYNAMIC_FEE_FLAG (0x800000) - // on the StaticNativeMath path. Dynamic-fee pools must take the classified path. + function test_computeFee_dynamicFee_skipsBuckets() public { + // Regression: a future routing bug must not evaluate buckets for DYNAMIC_FEE_FLAG. + // Dynamic-fee pools must take the classified path. vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(1_000_000); + policy.setFeeBuckets(_bucketsConfigB()); policy.setDefaultFee(FEE_100); vm.stopPrank(); @@ -578,11 +801,10 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(dynamicKey), FEE_100); } - function testFuzz_computeFee_staticNativeMath_multiplier(uint24 lpFee, uint24 pips) public { + function testFuzz_computeFee_staticNativeMath_buckets(uint24 lpFee) public { lpFee = uint24(bound(lpFee, 0, LPFeeLibrary.MAX_LP_FEE)); - pips = uint24(bound(pips, 0, 1_000_000)); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(pips); + policy.setFeeBuckets(_bucketsConfigB()); PoolKey memory k = standardKey; k.fee = lpFee; @@ -1016,7 +1238,7 @@ contract V4FeeAdapterTest is Test { function test_clearPairFee_fallsThroughToMultiplier() public { vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); vm.stopPrank(); @@ -1050,7 +1272,7 @@ contract V4FeeAdapterTest is Test { poolManager.mockInitialize(customKey); vm.startPrank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setDefaultFee(FEE_100); policy.setHookFamily(customHook, 1); policy.setFamilyDefault(1, FEE_200); @@ -1077,7 +1299,7 @@ contract V4FeeAdapterTest is Test { poolManager.setProtocolFeesAccrued(c, amount); vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); // Trigger fee update adapter.triggerFeeUpdate(standardKey); @@ -1109,7 +1331,7 @@ contract V4FeeAdapterTest is Test { function test_edge_policySwap() public { vm.prank(feeSetter); - policy.setProtocolFeeMultiplier(TEST_MULTIPLIER_PIPS); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); assertEq(adapter.getFee(standardKey), FEE_300); From 1b335c48e409eba077ba99ba96c82eebf1141578 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 8 May 2026 13:44:29 -0400 Subject: [PATCH 07/26] chore(fmt): lint fixes --- src/feeAdapters/V4FeePolicy.sol | 10 ++-------- src/interfaces/IV4FeePolicy.sol | 8 +++----- test/V4FeeAdapter.t.sol | 12 ++++-------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 38fc0f1b..9e0fd168 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -148,11 +148,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function flagRules(uint256 index) - external - view - returns (uint256 requiredFlags, uint8 familyId) - { + function flagRules(uint256 index) external view returns (uint256 requiredFlags, uint8 familyId) { FlagRule storage rule = _flagRules[index]; return (rule.requiredFlags, rule.familyId); } @@ -337,9 +333,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { if (flags != 0) { for (uint256 i; i < rulesLen; ++i) { FlagRule storage rule = _flagRules[i]; - if (flags & rule.requiredFlags == rule.requiredFlags) { - return rule.familyId; - } + if (flags & rule.requiredFlags == rule.requiredFlags) return rule.familyId; } } } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index eb66961c..07e0801e 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -12,7 +12,8 @@ import {Currency} from "v4-core/types/Currency.sol"; /// alpha + beta * (key.fee - floor) / 1_000_000 per direction (clamped to /// MAX_PROTOCOL_FEE). struct FeeBucket { - /// @dev LP-fee floor for this bucket, in pips. Ascending across the array; matches v4-core fee type. + /// @dev LP-fee floor for this bucket, in pips. Ascending across the array; matches v4-core fee + /// type. uint24 lpFeeFloor; /// @dev Flat base fee per direction in pips. Must be <= MAX_PROTOCOL_FEE (1000). uint24 alphaPips; @@ -190,10 +191,7 @@ interface IV4FeePolicy { /// @param index The zero-based index into the rules array. /// @return requiredFlags The flags that must all be present for a match. /// @return familyId The family ID assigned on match. - function flagRules(uint256 index) - external - view - returns (uint256 requiredFlags, uint8 familyId); + function flagRules(uint256 index) external view returns (uint256 requiredFlags, uint8 familyId); // --- Pure Classification --- diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 6b63f8d2..5806e062 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -1351,8 +1351,7 @@ contract V4FeeAdapterTest is Test { function test_setFlagRules_success() public { FlagRule[] memory rules = new FlagRule[](2); rules[0] = FlagRule({ - requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, - familyId: 3 + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 }); rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); @@ -1494,8 +1493,7 @@ contract V4FeeAdapterTest is Test { FlagRule[] memory rules = new FlagRule[](2); // More specific rule first: both flags required rules[0] = FlagRule({ - requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, - familyId: 3 + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 }); // Less specific: only one flag rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); @@ -1530,8 +1528,7 @@ contract V4FeeAdapterTest is Test { FlagRule[] memory rules = new FlagRule[](2); rules[0] = FlagRule({ - requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, - familyId: 3 + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 }); rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); @@ -1675,8 +1672,7 @@ contract V4FeeAdapterTest is Test { FlagRule[] memory rules = new FlagRule[](1); rules[0] = FlagRule({ - requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, - familyId: 3 + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 }); vm.startPrank(feeSetter); From c1bb3fa384d488a7ca4d781c75612573fad42a61 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 18 May 2026 15:00:07 -0400 Subject: [PATCH 08/26] fix(v4): fall back on rounded classified fees Ensure classified pair fee multipliers that truncate to zero use the family default while preserving explicit zero overrides. --- README.md | 2 ++ snapshots/V4FeeAdapterTest.json | 2 +- src/feeAdapters/V4FeeAdapter.sol | 1 + src/feeAdapters/V4FeePolicy.sol | 6 ++++- src/interfaces/IV4FeeAdapter.sol | 3 +++ src/interfaces/IV4FeePolicy.sol | 6 +++-- test/V4FeeAdapter.t.sol | 44 ++++++++++++++++++++++++++++++++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f3fb7835..02ed821e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1 2. gas-capped staticcall to `hook.protocolFeeFlags()` (optional `IFeeClassifiedHook` interface) → walk governance-configured `flagRules` first-match-wins 3. otherwise unclassified → falls through to `defaultFee` +With a non-zero family, the policy returns `pairFees[ph] × familyMultiplierPips[family] / 1_000_000` if both are set and the scaled result is non-zero, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair fee still short-circuits to zero; only truncation-to-zero from a non-zero pair fee falls through. + Permissioned roles: - **owner** — swaps the policy, sets the fee-setter diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 2c36a296..030ca521 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -7,7 +7,7 @@ "policy.computeFee - classified family default": "10090", "policy.computeFee - classified flag-rule self-report": "19991", "policy.computeFee - classified griefing hook -> defaultFee": "7721", - "policy.computeFee - classified pairFee * multiplier": "8312", + "policy.computeFee - classified pairFee * multiplier": "8384", "policy.computeFee - classified unclassified -> defaultFee": "7721", "policy.computeFee - flag-rule multi-flag match": "19991", "policy.computeFee - flag-rule single flag match": "19991", diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol index 97e898d9..0bf80024 100644 --- a/src/feeAdapters/V4FeeAdapter.sol +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -102,6 +102,7 @@ contract V4FeeAdapter is IV4FeeAdapter, Owned { /// @inheritdoc IV4FeeAdapter function setFeeSetter(address newFeeSetter) external onlyOwner { + if (newFeeSetter == address(0)) revert ZeroAddress(); emit FeeSetterUpdated(feeSetter, newFeeSetter); feeSetter = newFeeSetter; } diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 9e0fd168..8f1ede61 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -130,7 +130,11 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint24 multiplier = familyMultiplierPips[family]; if (pairFee != 0 && multiplier != 0) { - return _applyMultiplier(_decodeFee(pairFee), multiplier); + uint24 baseFee = _decodeFee(pairFee); + if (baseFee == 0) return 0; + + uint24 scaledFee = _applyMultiplier(baseFee, multiplier); + if (scaledFee != 0) return scaledFee; } uint24 famDefault = familyDefaults[family]; diff --git a/src/interfaces/IV4FeeAdapter.sol b/src/interfaces/IV4FeeAdapter.sol index 614970da..90c700e7 100644 --- a/src/interfaces/IV4FeeAdapter.sol +++ b/src/interfaces/IV4FeeAdapter.sol @@ -18,6 +18,9 @@ interface IV4FeeAdapter { /// @notice Thrown when an unauthorized address calls a restricted function. error Unauthorized(); + /// @notice Thrown when a zero address is provided. + error ZeroAddress(); + /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. error InvalidFeeValue(); diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 07e0801e..1bde1e4a 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -298,8 +298,10 @@ interface IV4FeePolicy { /// @notice Sets the pair fee for a token pair. /// @dev StaticNativeMath pools use this directly (overrides the fee buckets). - /// Classified pools scale it by familyMultiplierPips. Setting 0 sets explicit zero. - /// Use clearPairFee to remove entirely. + /// Classified pools scale it by familyMultiplierPips. If a nonzero pair fee scales to + /// zero because of integer truncation, the classified path falls through to the family + /// default. Setting 0 sets explicit zero and does not fall through. Use clearPairFee to + /// remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. /// @param feeValue The pair fee. Must pass isValidProtocolFee if non-zero. diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 5806e062..e30a5afd 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -863,6 +863,50 @@ contract V4FeeAdapterTest is Test { vm.snapshotGasLastCall("policy.computeFee - classified pairFee * multiplier"); } + function test_computeFee_classified_roundingToZeroFallsBackToFamilyDefault() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + uint24 fee50 = (50 << 12) | 50; + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairFee(customKey.currency0, customKey.currency1, FEE_100); + policy.setFamilyMultiplier(1, 9999); + policy.setFamilyDefault(1, fee50); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), fee50); + } + + function test_computeFee_classified_explicitZeroPairFeeDoesNotFallBack() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairFee(customKey.currency0, customKey.currency1, 0); + policy.setFamilyMultiplier(1, 500_000); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), 0); + } + function test_computeFee_classified_pairFeeAtMaxMultiplier() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ From b3496fef03bb9d149325e6de803d7e29c61e88bb Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 18 May 2026 16:46:31 -0400 Subject: [PATCH 09/26] fix(v4): disambiguate zero fee events Emit encoded fee storage values so indexers can distinguish explicit zero fees from cleared config. --- src/feeAdapters/V4FeeAdapter.sol | 7 ++++--- src/feeAdapters/V4FeePolicy.sol | 17 ++++++++------- src/interfaces/IV4FeeAdapter.sol | 4 +++- src/interfaces/IV4FeePolicy.sol | 12 ++++++++--- test/V4FeeAdapter.t.sol | 36 ++++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol index 0bf80024..22314b54 100644 --- a/src/feeAdapters/V4FeeAdapter.sol +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -112,8 +112,9 @@ contract V4FeeAdapter is IV4FeeAdapter, Owned { /// @inheritdoc IV4FeeAdapter function setPoolOverride(PoolId poolId, uint24 feeValue) external onlyFeeSetter { if (feeValue != 0) _validateFee(feeValue); - poolOverrides[poolId] = _encodeFee(feeValue); - emit PoolOverrideUpdated(poolId, feeValue); + uint24 stored = _encodeFee(feeValue); + poolOverrides[poolId] = stored; + emit PoolOverrideUpdated(poolId, stored); } /// @inheritdoc IV4FeeAdapter @@ -142,7 +143,7 @@ contract V4FeeAdapter is IV4FeeAdapter, Owned { /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in /// storage means "not set" rather than "explicitly zero". - /// @param feeValue The actual fee value (0 = remove/unset). + /// @param feeValue The actual fee value (0 = explicit zero). /// @return The encoded value to store. function _encodeFee(uint24 feeValue) internal pure returns (uint24) { return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 8f1ede61..79da91b8 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -214,8 +214,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function setDefaultFee(uint24 feeValue) external onlyFeeSetter { if (feeValue != 0) _validateFee(feeValue); - defaultFee = _encodeFee(feeValue); - emit DefaultFeeUpdated(feeValue); + uint24 stored = _encodeFee(feeValue); + defaultFee = stored; + emit DefaultFeeUpdated(stored); } /// @inheritdoc IV4FeePolicy @@ -258,8 +259,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { function setFamilyDefault(uint8 familyId, uint24 feeValue) external onlyFeeSetter { if (familyId == 0) revert InvalidFamilyId(); if (feeValue != 0) _validateFee(feeValue); - familyDefaults[familyId] = _encodeFee(feeValue); - emit FamilyDefaultUpdated(familyId, feeValue); + uint24 stored = _encodeFee(feeValue); + familyDefaults[familyId] = stored; + emit FamilyDefaultUpdated(familyId, stored); } /// @inheritdoc IV4FeePolicy @@ -292,8 +294,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); - pairFees[ph] = _encodeFee(feeValue); - emit PairFeeUpdated(ph, feeValue); + uint24 stored = _encodeFee(feeValue); + pairFees[ph] = stored; + emit PairFeeUpdated(ph, stored); } /// @inheritdoc IV4FeePolicy @@ -402,7 +405,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in /// storage means "not set" rather than "explicitly zero". - /// @param feeValue The actual fee value (0 = remove/unset). + /// @param feeValue The actual fee value (0 = explicit zero). /// @return The encoded value to store. function _encodeFee(uint24 feeValue) internal pure returns (uint24) { return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; diff --git a/src/interfaces/IV4FeeAdapter.sol b/src/interfaces/IV4FeeAdapter.sol index 90c700e7..1d9d88bf 100644 --- a/src/interfaces/IV4FeeAdapter.sol +++ b/src/interfaces/IV4FeeAdapter.sol @@ -32,8 +32,10 @@ interface IV4FeeAdapter { event PolicyUpdated(address indexed oldPolicy, address indexed newPolicy); /// @notice Emitted when a pool override is set or removed. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. /// @param poolId The pool whose override changed. - /// @param feeValue The new fee value (0 means override was removed). + /// @param feeValue The new encoded fee value. event PoolOverrideUpdated(PoolId indexed poolId, uint24 feeValue); /// @notice Emitted when the fee setter address is updated. diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 1bde1e4a..0ff53b80 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -92,8 +92,10 @@ interface IV4FeePolicy { event HookFamilySet(address indexed hook, uint8 familyId); /// @notice Emitted when a family's default protocol fee is updated. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. /// @param familyId The family whose default was changed. - /// @param feeValue The new default fee (0 = removed). + /// @param feeValue The new encoded default fee. event FamilyDefaultUpdated(uint8 indexed familyId, uint24 feeValue); /// @notice Emitted when a family's multiplier is updated. @@ -102,8 +104,10 @@ interface IV4FeePolicy { event FamilyMultiplierUpdated(uint8 indexed familyId, uint24 multiplierPips); /// @notice Emitted when a pair fee is updated. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. /// @param pairHash The canonical hash of the token pair. - /// @param feeValue The new pair fee (0 = removed). + /// @param feeValue The new encoded pair fee. event PairFeeUpdated(bytes32 indexed pairHash, uint24 feeValue); /// @notice Emitted when the fee buckets array is replaced. @@ -111,7 +115,9 @@ interface IV4FeePolicy { event FeeBucketsUpdated(uint256 bucketCount); /// @notice Emitted when the default classified fee is updated. - /// @param feeValue The new default fee (0 = removed). + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. + /// @param feeValue The new encoded default fee. event DefaultFeeUpdated(uint24 feeValue); /// @notice Emitted when the flag rules array is replaced. diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index e30a5afd..e242850d 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -238,6 +238,8 @@ contract V4FeeAdapterTest is Test { assertEq(adapter.getFee(standardKey), FEE_300); // Set pool override to explicit zero -should NOT fall through to policy + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, type(uint24).max); vm.prank(feeSetter); adapter.setPoolOverride(id, 0); @@ -257,6 +259,8 @@ contract V4FeeAdapterTest is Test { adapter.setPoolOverride(id, FEE_500); assertEq(adapter.getFee(standardKey), FEE_500); + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, 0); adapter.clearPoolOverride(id); vm.stopPrank(); @@ -1231,6 +1235,38 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(customKey), 0); } + function test_sentinel_updateEventsDistinguishExplicitZeroAndClear() public { + bytes32 ph = _pairHash(); + + vm.startPrank(feeSetter); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(type(uint24).max); + policy.setDefaultFee(0); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(0); + policy.clearDefaultFee(); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, type(uint24).max); + policy.setFamilyDefault(1, 0); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, 0); + policy.clearFamilyDefault(1); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.PairFeeUpdated(ph, type(uint24).max); + policy.setPairFee(standardKey.currency0, standardKey.currency1, 0); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.PairFeeUpdated(ph, 0); + policy.clearPairFee(standardKey.currency0, standardKey.currency1); + + vm.stopPrank(); + } + function test_sentinel_clearFallsThrough() public { // clearFamilyDefault deletes storage -> falls through to defaultFee address customHook = address(uint160((1 << 7) | (1 << 2))); From 005c925c87a1a7fbb072a5b1761483213c71b0b6 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 18 May 2026 16:46:49 -0400 Subject: [PATCH 10/26] fix(v4): bound hook self-report returndata Limit protocolFeeFlags returndata copying to one word so hooks cannot inflate caller gas with oversized return data. --- src/feeAdapters/V4FeePolicy.sol | 26 +++++++++++++-------- test/V4FeeAdapter.t.sol | 35 +++++++++++++++++++++++++++- test/mocks/MockFeeClassifiedHook.sol | 10 ++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 79da91b8..1efc907c 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -332,16 +332,22 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint256 rulesLen = _flagRules.length; if (rulesLen == 0) return 0; - (bool ok, bytes memory ret) = hook.staticcall{gas: SELF_REPORT_GAS_LIMIT}( - abi.encodeCall(IFeeClassifiedHook.protocolFeeFlags, ()) - ); - if (ok && ret.length >= 32) { - uint256 flags = abi.decode(ret, (uint256)); - if (flags != 0) { - for (uint256 i; i < rulesLen; ++i) { - FlagRule storage rule = _flagRules[i]; - if (flags & rule.requiredFlags == rule.requiredFlags) return rule.familyId; - } + uint256 flags; + bool ok; + uint256 gasLimit = SELF_REPORT_GAS_LIMIT; + uint256 selector = uint32(IFeeClassifiedHook.protocolFeeFlags.selector); + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, shl(224, selector)) + ok := staticcall(gasLimit, hook, ptr, 0x04, ptr, 0x20) + ok := and(ok, iszero(lt(returndatasize(), 0x20))) + flags := mload(ptr) + } + if (ok && flags != 0) { + for (uint256 i; i < rulesLen; ++i) { + FlagRule storage rule = _flagRules[i]; + uint256 requiredFlags = rule.requiredFlags; + if (flags & requiredFlags == requiredFlags) return rule.familyId; } } diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index e242850d..9d8bd4ee 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -19,7 +19,8 @@ import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { MockFeeClassifiedHook, GriefingHook, - RevertingHook + RevertingHook, + ReturnBombHook } from "./mocks/MockFeeClassifiedHook.sol"; contract V4FeeAdapterTest is Test { @@ -1090,6 +1091,38 @@ contract V4FeeAdapterTest is Test { vm.snapshotGasLastCall("policy.computeFee - classified griefing hook -> defaultFee"); } + function test_computeFee_selfReport_boundsReturnDataCopy() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + ReturnBombHook impl = new ReturnBombHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(1, FEE_300); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + uint256 gasBefore = gasleft(); + uint24 fee = policy.computeFee(key); + uint256 gasUsed = gasBefore - gasleft(); + + assertEq(fee, FEE_300); + assertLt(gasUsed, 45_000); + } + // ============ Policy: Configuration Functions ============ function test_setHookFamily_success() public { diff --git a/test/mocks/MockFeeClassifiedHook.sol b/test/mocks/MockFeeClassifiedHook.sol index 13ff103a..efe8b67e 100644 --- a/test/mocks/MockFeeClassifiedHook.sol +++ b/test/mocks/MockFeeClassifiedHook.sol @@ -30,3 +30,13 @@ contract RevertingHook { revert("not classified"); } } + +/// @notice Mock hook that returns valid flags followed by excess returndata. +contract ReturnBombHook { + function protocolFeeFlags() external pure returns (uint256) { + assembly ("memory-safe") { + mstore(0, 1) + return(0, 0x10000) + } + } +} From 50e65c4a0c760b8d5f076cfa9168b20def6d5f47 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 18 May 2026 16:47:00 -0400 Subject: [PATCH 11/26] chore(v4): apply fee adapter cleanups Cache repeated reads and remove unused V4 policy imports to address low-risk audit cleanup items. --- src/feeAdapters/V4FeeAdapter.sol | 8 +++++--- src/feeAdapters/V4FeePolicy.sol | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol index 22314b54..456453a3 100644 --- a/src/feeAdapters/V4FeeAdapter.sol +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -62,8 +62,9 @@ contract V4FeeAdapter is IV4FeeAdapter, Owned { function getFee(PoolKey memory key) public view returns (uint24) { uint24 stored = poolOverrides[key.toId()]; if (stored != 0) return _decodeFee(stored); - if (address(policy) == address(0)) return 0; - return policy.computeFee(key); + IV4FeePolicy currentPolicy = policy; + if (address(currentPolicy) == address(0)) return 0; + return currentPolicy.computeFee(key); } // ─── Permissionless Triggering ─── @@ -75,7 +76,8 @@ contract V4FeeAdapter is IV4FeeAdapter, Owned { /// @inheritdoc IV4FeeAdapter function batchTriggerFeeUpdate(PoolKey[] calldata keys) external { - for (uint256 i; i < keys.length; ++i) { + uint256 length = keys.length; + for (uint256 i; i < length; ++i) { _setProtocolFee(keys[i]); } } diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 1efc907c..77464426 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.26; import {Owned} from "solmate/src/auth/Owned.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; -import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; @@ -30,7 +29,6 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @custom:security-contact security@uniswap.org contract V4FeePolicy is IV4FeePolicy, Owned { using LPFeeLibrary for uint24; - using PoolIdLibrary for PoolKey; /// @dev Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). uint160 public constant CUSTOM_ACCOUNTING_MASK = 0xF; @@ -378,7 +376,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { // Default to the lowest bucket so the snap case (lpFee < floor_0) falls out // naturally below with delta = 0. FeeBucket memory bucket = _feeBuckets[0]; - for (uint256 i = len; i > 0; --i) { + for (uint256 i = len; i > 1; --i) { FeeBucket memory candidate = _feeBuckets[i - 1]; if (candidate.lpFeeFloor <= lpFee) { bucket = candidate; From a6690776a30fe11141846974475e5bacc1519036 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Mon, 18 May 2026 16:47:04 -0400 Subject: [PATCH 12/26] chore(v4): refresh fee adapter gas snapshots Update V4 adapter gas snapshots after the audit follow-up fixes and cleanup changes. --- snapshots/V4FeeAdapterTest.json | 44 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 030ca521..e0714fa1 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,24 +1,24 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "76961", - "adapter.collect - single currency": "58075", - "adapter.getFee - pool override hit": "3329", - "adapter.setPoolOverride": "48080", - "adapter.triggerFeeUpdate - single pool": "56322", - "policy.computeFee - classified family default": "10090", - "policy.computeFee - classified flag-rule self-report": "19991", - "policy.computeFee - classified griefing hook -> defaultFee": "7721", - "policy.computeFee - classified pairFee * multiplier": "8384", - "policy.computeFee - classified unclassified -> defaultFee": "7721", - "policy.computeFee - flag-rule multi-flag match": "19991", - "policy.computeFee - flag-rule single flag match": "19991", - "policy.computeFee - static native math buckets": "9307", - "policy.computeFee - static native math pair fee": "3577", - "policy.setFamilyDefault": "47836", - "policy.setFamilyMultiplier": "47683", - "policy.setFeeBuckets - 1 bucket": "71383", - "policy.setFeeBuckets - 5 buckets (configuration B)": "148215", - "policy.setFlagRules - 32 rules (max)": "1493907", - "policy.setFlagRules - two rules": "137727", - "policy.setHookFamily": "47567", - "policy.setPairFee": "48696" + "adapter.batchTriggerFeeUpdate - two pools": "45502", + "adapter.collect - single currency": "31151", + "adapter.getFee - pool override hit": "1329", + "adapter.setPoolOverride": "26352", + "adapter.triggerFeeUpdate - single pool": "26954", + "policy.computeFee - classified family default": "6090", + "policy.computeFee - classified flag-rule self-report": "9099", + "policy.computeFee - classified griefing hook -> defaultFee": "5721", + "policy.computeFee - classified pairFee * multiplier": "2384", + "policy.computeFee - classified unclassified -> defaultFee": "5721", + "policy.computeFee - flag-rule multi-flag match": "9099", + "policy.computeFee - flag-rule single flag match": "9099", + "policy.computeFee - static native math buckets": "4760", + "policy.computeFee - static native math pair fee": "1577", + "policy.setFamilyDefault": "26469", + "policy.setFamilyMultiplier": "26315", + "policy.setFeeBuckets - 1 bucket": "49619", + "policy.setFeeBuckets - 5 buckets (configuration B)": "124699", + "policy.setFlagRules - 32 rules (max)": "1463615", + "policy.setFlagRules - two rules": "115835", + "policy.setHookFamily": "26223", + "policy.setPairFee": "26744" } \ No newline at end of file From f17594cd7e7fa8f31a8b1d7a748d4d86124288de Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 19 May 2026 13:18:12 -0400 Subject: [PATCH 13/26] chore(qa): correct license strings, remove unused import --- snapshots/V4FeeAdapterForkTest.json | 4 +-- snapshots/V4FeeAdapterTest.json | 44 ++++++++++++++--------------- src/feeAdapters/V4FeeAdapter.sol | 5 ++-- src/feeAdapters/V4FeePolicy.sol | 4 +-- src/interfaces/IV4FeeAdapter.sol | 4 +-- src/interfaces/IV4FeePolicy.sol | 4 +-- 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 7c101a7c..9b2b8c11 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "97188", + "fork: batchTriggerFeeUpdate 3 pools": "95249", "fork: collect 2 currencies": "99567", "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "56382" + "fork: triggerFeeUpdate single pool": "55734" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index e0714fa1..d8bdc6c5 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,24 +1,24 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "45502", - "adapter.collect - single currency": "31151", - "adapter.getFee - pool override hit": "1329", - "adapter.setPoolOverride": "26352", - "adapter.triggerFeeUpdate - single pool": "26954", - "policy.computeFee - classified family default": "6090", - "policy.computeFee - classified flag-rule self-report": "9099", - "policy.computeFee - classified griefing hook -> defaultFee": "5721", - "policy.computeFee - classified pairFee * multiplier": "2384", - "policy.computeFee - classified unclassified -> defaultFee": "5721", - "policy.computeFee - flag-rule multi-flag match": "9099", - "policy.computeFee - flag-rule single flag match": "9099", - "policy.computeFee - static native math buckets": "4760", - "policy.computeFee - static native math pair fee": "1577", - "policy.setFamilyDefault": "26469", - "policy.setFamilyMultiplier": "26315", - "policy.setFeeBuckets - 1 bucket": "49619", - "policy.setFeeBuckets - 5 buckets (configuration B)": "124699", - "policy.setFlagRules - 32 rules (max)": "1463615", - "policy.setFlagRules - two rules": "115835", - "policy.setHookFamily": "26223", - "policy.setPairFee": "26744" + "adapter.batchTriggerFeeUpdate - two pools": "75679", + "adapter.collect - single currency": "58078", + "adapter.getFee - pool override hit": "3330", + "adapter.setPoolOverride": "48093", + "adapter.triggerFeeUpdate - single pool": "55678", + "policy.computeFee - classified family default": "10090", + "policy.computeFee - classified flag-rule self-report": "19599", + "policy.computeFee - classified griefing hook -> defaultFee": "7721", + "policy.computeFee - classified pairFee * multiplier": "8384", + "policy.computeFee - classified unclassified -> defaultFee": "7721", + "policy.computeFee - flag-rule multi-flag match": "19599", + "policy.computeFee - flag-rule single flag match": "19599", + "policy.computeFee - static native math buckets": "8760", + "policy.computeFee - static native math pair fee": "3577", + "policy.setFamilyDefault": "47838", + "policy.setFamilyMultiplier": "47684", + "policy.setFeeBuckets - 1 bucket": "71385", + "policy.setFeeBuckets - 5 buckets (configuration B)": "148217", + "policy.setFlagRules - 32 rules (max)": "1493920", + "policy.setFlagRules - two rules": "137740", + "policy.setHookFamily": "47568", + "policy.setPairFee": "48709" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol index 456453a3..f7b3aa58 100644 --- a/src/feeAdapters/V4FeeAdapter.sol +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -1,11 +1,10 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.26; +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; import {Owned} from "solmate/src/auth/Owned.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; -import {Currency} from "v4-core/types/Currency.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {IV4FeeAdapter} from "../interfaces/IV4FeeAdapter.sol"; diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 77464426..be6aef8e 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -1,5 +1,5 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.26; +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; import {Owned} from "solmate/src/auth/Owned.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; diff --git a/src/interfaces/IV4FeeAdapter.sol b/src/interfaces/IV4FeeAdapter.sol index 1d9d88bf..7813a22d 100644 --- a/src/interfaces/IV4FeeAdapter.sol +++ b/src/interfaces/IV4FeeAdapter.sol @@ -1,5 +1,5 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.26; +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 0ff53b80..8c4460d1 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -1,5 +1,5 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.26; +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; From c75ba73d99c4c50e127d83418b460799900f582a Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Tue, 19 May 2026 16:11:00 -0400 Subject: [PATCH 14/26] chore(qa): no unwrapping, reuse constants --- snapshots/V4FeeAdapterTest.json | 6 +++--- src/feeAdapters/V4FeePolicy.sol | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index d8bdc6c5..4d2c856d 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -15,10 +15,10 @@ "policy.computeFee - static native math pair fee": "3577", "policy.setFamilyDefault": "47838", "policy.setFamilyMultiplier": "47684", - "policy.setFeeBuckets - 1 bucket": "71385", - "policy.setFeeBuckets - 5 buckets (configuration B)": "148217", + "policy.setFeeBuckets - 1 bucket": "71479", + "policy.setFeeBuckets - 5 buckets (configuration B)": "148687", "policy.setFlagRules - 32 rules (max)": "1493920", "policy.setFlagRules - two rules": "137740", "policy.setHookFamily": "47568", - "policy.setPairFee": "48709" + "policy.setPairFee": "48712" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index be6aef8e..c74df8a7 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -40,19 +40,20 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; - /// @dev Shared denominator for pips-based multipliers. 1_000_000 = 100% (matches - /// MAX_LP_FEE). Used by the StaticNativeMath bucket schedule and by family multipliers - /// on the classified path. - uint24 internal constant MULTIPLIER_DENOMINATOR = 1_000_000; + /// @dev Shared denominator for pips-based multipliers. 1_000_000 = 100% (matches the + /// v4-core LP fee denominator). Used by the StaticNativeMath bucket schedule and by + /// family multipliers on the classified path. + uint24 internal constant MULTIPLIER_DENOMINATOR = LPFeeLibrary.MAX_LP_FEE; /// @dev Maximum number of fee buckets. Bounds the backward walk in /// `_computeStaticNativeMathFee`. uint256 internal constant MAX_BUCKETS = 16; /// @dev Maximum `betaPips` per bucket. Above this, even a 1-pip delta exceeds - /// MAX_PROTOCOL_FEE (1000), so the per-direction clamp is always hit and values above - /// are functionally identical noise. - uint32 internal constant MAX_BETA_PIPS = 1_000_000_000; + /// MAX_PROTOCOL_FEE, so the per-direction clamp is always hit and values above are + /// functionally identical noise. + uint32 internal constant MAX_BETA_PIPS = + uint32(uint256(MULTIPLIER_DENOMINATOR) * ProtocolFeeLibrary.MAX_PROTOCOL_FEE); /// @inheritdoc IV4FeePolicy IPoolManager public immutable POOL_MANAGER; @@ -287,9 +288,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { external onlyFeeSetter { - if (Currency.unwrap(currency0) >= Currency.unwrap(currency1)) { - revert CurrenciesOutOfOrder(); - } + if (currency0 >= currency1) revert CurrenciesOutOfOrder(); if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); uint24 stored = _encodeFee(feeValue); @@ -299,7 +298,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function clearPairFee(Currency currency0, Currency currency1) external onlyFeeSetter { - if (Currency.unwrap(currency0) >= Currency.unwrap(currency1)) revert CurrenciesOutOfOrder(); + if (currency0 >= currency1) revert CurrenciesOutOfOrder(); bytes32 ph = _pairHash(currency0, currency1); delete pairFees[ph]; emit PairFeeUpdated(ph, 0); From 5eda399ab2e2c0322ef73c94aedbe0e42ad28f02 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Wed, 20 May 2026 15:06:41 -0400 Subject: [PATCH 15/26] feat(v4): validate flag rule ordering --- remappings.txt | 1 + snapshots/V4FeeAdapterTest.json | 4 ++-- src/feeAdapters/V4FeePolicy.sol | 5 +++++ src/interfaces/IV4FeePolicy.sol | 8 ++++++-- test/V4FeeAdapter.t.sol | 12 ++++++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/remappings.txt b/remappings.txt index 97dde7f0..4f0f371f 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,5 @@ solmate/=lib/solmate/ +solady/=lib/optimism/packages/contracts-bedrock/lib/solady/ @eth-optimism-bedrock/=lib/optimism/packages/contracts-bedrock eas-contracts/=lib/dao-signer/lib/eas-contracts/contracts/ scripts/libraries/Config.sol=lib/optimism/packages/contracts-bedrock/scripts/libraries/Config.sol diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 4d2c856d..f05b379d 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -17,8 +17,8 @@ "policy.setFamilyMultiplier": "47684", "policy.setFeeBuckets - 1 bucket": "71479", "policy.setFeeBuckets - 5 buckets (configuration B)": "148687", - "policy.setFlagRules - 32 rules (max)": "1493920", - "policy.setFlagRules - two rules": "137740", + "policy.setFlagRules - 32 rules (max)": "1499865", + "policy.setFlagRules - two rules": "138105", "policy.setHookFamily": "47568", "policy.setPairFee": "48712" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index c74df8a7..d4ead093 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -7,6 +7,7 @@ import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {LibBit} from "solady/src/utils/LibBit.sol"; import {IV4FeePolicy, FlagRule, FeeBucket} from "../interfaces/IV4FeePolicy.sol"; import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; @@ -195,9 +196,13 @@ contract V4FeePolicy is IV4FeePolicy, Owned { delete _flagRules; + uint256 previousSpecificity = type(uint256).max; for (uint256 i; i < rules.length; ++i) { FlagRule calldata rule = rules[i]; if (rule.requiredFlags == 0 || rule.familyId == 0) revert InvalidFlagRule(); + uint256 specificity = LibBit.popCount(rule.requiredFlags); + if (specificity > previousSpecificity) revert FlagRulesNotSorted(); + previousSpecificity = specificity; _flagRules.push(rule); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 8c4460d1..24cf6fd0 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -70,6 +70,9 @@ interface IV4FeePolicy { /// @notice Thrown when flag rules exceed the maximum allowed count. error TooManyFlagRules(); + /// @notice Thrown when flag rules are not sorted from most to least specific. + error FlagRulesNotSorted(); + /// @notice Thrown when setFeeBuckets is called with an empty array. error EmptyBuckets(); @@ -242,8 +245,9 @@ interface IV4FeePolicy { /// @notice Replaces the entire flag rules array atomically. /// @dev Rules are checked in order; the first rule whose requiredFlags are all present - /// in the hook's self-reported flags wins. More specific patterns should come first. - /// Each rule must have requiredFlags != 0 and familyId > 0. Max 32 rules. + /// in the hook's self-reported flags wins. Rules must be sorted by non-increasing + /// number of set bits in requiredFlags, so more specific patterns come first. Each + /// rule must have requiredFlags != 0 and familyId > 0. Max 32 rules. /// @param rules The new flag rules, ordered by match priority (first match wins). function setFlagRules(FlagRule[] calldata rules) external; diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 9d8bd4ee..4c708edb 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -1527,6 +1527,18 @@ contract V4FeeAdapterTest is Test { policy.setFlagRules(rules); } + function test_setFlagRules_revertsIfSpecificRuleFollowsBroadRule() public { + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + rules[1] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.FlagRulesNotSorted.selector); + policy.setFlagRules(rules); + } + function test_setFlagRules_revertsTooManyRules() public { FlagRule[] memory rules = new FlagRule[](33); for (uint256 i; i < 33; ++i) { From 8138b86487242d5b81bfbe3cf3d6c9d79b4fbe7c Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Wed, 27 May 2026 14:24:48 -0400 Subject: [PATCH 16/26] feat(v4): replace pair fees with per-family pairClassFees Use pairClassFees[pair][family] with NATIVE_MATH_FAMILY_ID for StaticNativeMath overrides, literal classified fees per family, and remove family multipliers so aggregator and native fee rollouts do not share a single pair-level knob. --- README.md | 10 +- snapshots/V4FeeAdapterForkTest.json | 8 +- snapshots/V4FeeAdapterTest.json | 43 ++++--- src/feeAdapters/V4FeePolicy.sol | 144 ++++++--------------- src/interfaces/IV4FeePolicy.sol | 119 ++++++++---------- test/V4FeeAdapter.fork.t.sol | 9 +- test/V4FeeAdapter.t.sol | 186 ++++++++++++++-------------- 7 files changed, 211 insertions(+), 308 deletions(-) diff --git a/README.md b/README.md index 02ed821e..1735a180 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ adapter.poolOverrides[poolId] ──► return (sentinel-decoded) │ └──► policy.computeFee(key) │ - ├── Path A: StaticNativeMath ──► pairFees[pair] OR + ├── Path A: StaticNativeMath ──► pairClassFees[pair][NATIVE_MATH_FAMILY_ID] OR │ walk feeBuckets backward, find largest │ lpFeeFloor <= key.fee (snap to bucket 0 │ if key.fee < floor_0); return @@ -134,7 +134,7 @@ adapter.poolOverrides[poolId] ──► return (sentinel-decoded) └── Path B: Classified ────────► familyId resolved from hookFamilyId[hook] OR hook-reported flags │ - └─► pairFee × familyMultiplierPips OR + └─► pairClassFees[pair][family] OR familyDefaults[family] OR defaultFee ``` @@ -143,7 +143,7 @@ A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA Path A's fee buckets are an ascending-by-`lpFeeFloor` array (max 16) of `(lpFeeFloor, alpha, beta)` triples. Each bucket's `alpha` is a flat per-direction base fee (≤ `MAX_PROTOCOL_FEE = 1000`), and `beta` is a slope in pips per pip of `(lpFee - floor)` (≤ 1_000_000_000). Setting `beta = 0` yields a pure step function; `alpha = 0` yields a slope-only multiplier; both nonzero yields a piecewise-linear curve. Continuity at boundaries is governance's responsibility — the contract does not enforce it. The lowest bucket's `alpha` doubles as a minimum-fee floor for very-low-LP-fee pools, since `key.fee < floor_0` snaps to bucket 0 with `delta = 0`. -Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`). Family multipliers on Path B are capped at 100%; bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts setters that exceed their respective caps. +Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) for bucket slope math. Bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts bucket setters that exceed that cap. `NATIVE_MATH_FAMILY_ID` (0) in `pairClassFees` is reserved for StaticNativeMath pair overrides — distinct from `hookFamilyId == 0` (unclassified hook). `familyId` resolution for Path B: @@ -151,12 +151,12 @@ Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1 2. gas-capped staticcall to `hook.protocolFeeFlags()` (optional `IFeeClassifiedHook` interface) → walk governance-configured `flagRules` first-match-wins 3. otherwise unclassified → falls through to `defaultFee` -With a non-zero family, the policy returns `pairFees[ph] × familyMultiplierPips[family] / 1_000_000` if both are set and the scaled result is non-zero, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair fee still short-circuits to zero; only truncation-to-zero from a non-zero pair fee falls through. +With a non-zero family, the policy returns `pairClassFees[ph][family]` if set, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair class fee still short-circuits to zero. Unclassified hooks (`family == 0`) use `defaultFee` only. Permissioned roles: - **owner** — swaps the policy, sets the fee-setter -- **feeSetter** — configures pool overrides, pair fees, hook families, flag rules, family defaults/multipliers, the fee buckets, and `defaultFee` +- **feeSetter** — configures pool overrides, pair class fees, hook families, flag rules, family defaults, the fee buckets, and `defaultFee` ### 3. Releasers diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 9b2b8c11..9db1efea 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "95249", - "fork: collect 2 currencies": "99567", - "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "55734" + "fork: batchTriggerFeeUpdate 3 pools": "64153", + "fork: collect 2 currencies": "58131", + "fork: collect single currency": "29307", + "fork: triggerFeeUpdate single pool": "27086" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index f05b379d..b6b5e133 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,24 +1,23 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "75679", - "adapter.collect - single currency": "58078", - "adapter.getFee - pool override hit": "3330", - "adapter.setPoolOverride": "48093", - "adapter.triggerFeeUpdate - single pool": "55678", - "policy.computeFee - classified family default": "10090", - "policy.computeFee - classified flag-rule self-report": "19599", - "policy.computeFee - classified griefing hook -> defaultFee": "7721", - "policy.computeFee - classified pairFee * multiplier": "8384", - "policy.computeFee - classified unclassified -> defaultFee": "7721", - "policy.computeFee - flag-rule multi-flag match": "19599", - "policy.computeFee - flag-rule single flag match": "19599", - "policy.computeFee - static native math buckets": "8760", - "policy.computeFee - static native math pair fee": "3577", - "policy.setFamilyDefault": "47838", - "policy.setFamilyMultiplier": "47684", - "policy.setFeeBuckets - 1 bucket": "71479", - "policy.setFeeBuckets - 5 buckets (configuration B)": "148687", - "policy.setFlagRules - 32 rules (max)": "1499865", - "policy.setFlagRules - two rules": "138105", - "policy.setHookFamily": "47568", - "policy.setPairFee": "48712" + "adapter.batchTriggerFeeUpdate - two pools": "45655", + "adapter.collect - single currency": "31154", + "adapter.getFee - pool override hit": "1330", + "adapter.setPoolOverride": "26353", + "adapter.triggerFeeUpdate - single pool": "27030", + "policy.computeFee - classified family default": "3947", + "policy.computeFee - classified flag-rule self-report": "6956", + "policy.computeFee - classified griefing hook -> defaultFee": "5722", + "policy.computeFee - classified pair class fee": "1742", + "policy.computeFee - classified unclassified -> defaultFee": "5722", + "policy.computeFee - flag-rule multi-flag match": "6956", + "policy.computeFee - flag-rule single flag match": "6956", + "policy.computeFee - static native math buckets": "4832", + "policy.computeFee - static native math pair class fee": "1649", + "policy.setFamilyDefault": "26491", + "policy.setFeeBuckets - 1 bucket": "49650", + "policy.setFeeBuckets - 5 buckets (configuration B)": "125106", + "policy.setFlagRules - 32 rules (max)": "1469583", + "policy.setFlagRules - two rules": "116223", + "policy.setHookFamily": "26223", + "policy.setPairClassFee": "27283" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index d4ead093..f3731a95 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -15,14 +15,10 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification /// and a piecewise-linear schedule of fee buckets. /// @dev Pools are classified into two paths: -/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → pair fee, else evaluate -/// the fee-bucket schedule: find the bucket with the largest `lpFeeFloor <= key.fee` -/// (snap to bucket 0 if `key.fee < floor_0`) and return -/// `alpha + beta * (key.fee - floor) / 1_000_000` per direction (clamped to -/// MAX_PROTOCOL_FEE, packed symmetrically). The lowest bucket's `alpha` doubles as a -/// minimum-fee floor for very-low-LP-fee pools. -/// - Classified: custom accounting or dynamic fee → family multiplier × pair fee, family -/// default, or global default fee. +/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → +/// `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]`, else evaluate the fee-bucket schedule. +/// - Classified: custom accounting or dynamic fee → resolve family, then +/// `pairClassFees[pair][family]` → `familyDefaults[family]` → `defaultFee`. /// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). /// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(). /// Governance-configured flag rules map flag patterns to families automatically. @@ -31,6 +27,9 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; contract V4FeePolicy is IV4FeePolicy, Owned { using LPFeeLibrary for uint24; + /// @inheritdoc IV4FeePolicy + uint8 public constant NATIVE_MATH_FAMILY_ID = 0; + /// @dev Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). uint160 public constant CUSTOM_ACCOUNTING_MASK = 0xF; @@ -41,9 +40,8 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; - /// @dev Shared denominator for pips-based multipliers. 1_000_000 = 100% (matches the - /// v4-core LP fee denominator). Used by the StaticNativeMath bucket schedule and by - /// family multipliers on the classified path. + /// @dev Shared denominator for pips-based bucket slopes. 1_000_000 = 100% (matches the + /// v4-core LP fee denominator). uint24 internal constant MULTIPLIER_DENOMINATOR = LPFeeLibrary.MAX_LP_FEE; /// @dev Maximum number of fee buckets. Bounds the backward walk in @@ -72,14 +70,11 @@ contract V4FeePolicy is IV4FeePolicy, Owned { mapping(uint8 familyId => uint24) public familyDefaults; /// @inheritdoc IV4FeePolicy - mapping(uint8 familyId => uint24) public familyMultiplierPips; - - /// @inheritdoc IV4FeePolicy - mapping(bytes32 pairHash => uint24) public pairFees; + mapping(bytes32 pairHash => mapping(uint8 familyId => uint24)) public pairClassFees; /// @dev Ordered fee buckets for the StaticNativeMath path. Ascending by /// `lpFeeFloor`. Set atomically via `setFeeBuckets`. Empty array → Path A returns 0 - /// (when no pair-fee override exists). + /// when no native pair class fee override exists. FeeBucket[] internal _feeBuckets; /// @dev Maximum number of flag rules to bound gas in _resolveFamily. @@ -117,29 +112,20 @@ contract V4FeePolicy is IV4FeePolicy, Owned { // StaticNativeMath: no custom accounting + static fee if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) { - uint24 stored = pairFees[ph]; + uint24 stored = pairClassFees[ph][NATIVE_MATH_FAMILY_ID]; if (stored != 0) return _decodeFee(stored); return _computeStaticNativeMathFee(key.fee); } // Classified: custom accounting OR dynamic fee - // Priority: governance override → flag-rule match → unclassified uint8 family = _resolveFamily(hook); - if (family != 0) { - uint24 pairFee = pairFees[ph]; - uint24 multiplier = familyMultiplierPips[family]; + if (family == 0) return _decodeFee(defaultFee); - if (pairFee != 0 && multiplier != 0) { - uint24 baseFee = _decodeFee(pairFee); - if (baseFee == 0) return 0; + uint24 pairStored = pairClassFees[ph][family]; + if (pairStored != 0) return _decodeFee(pairStored); - uint24 scaledFee = _applyMultiplier(baseFee, multiplier); - if (scaledFee != 0) return scaledFee; - } - - uint24 famDefault = familyDefaults[family]; - if (famDefault != 0) return _decodeFee(famDefault); - } + uint24 famDefault = familyDefaults[family]; + if (famDefault != 0) return _decodeFee(famDefault); return _decodeFee(defaultFee); } @@ -241,9 +227,6 @@ contract V4FeePolicy is IV4FeePolicy, Owned { for (uint256 i; i < len; ++i) { FeeBucket calldata b = buckets[i]; if (i > 0 && b.lpFeeFloor <= prevFloor) revert BucketsNotAscending(); - // alpha is a single per-direction value, so check directly against - // MAX_PROTOCOL_FEE rather than reusing _validateFee (which expects a packed - // two-component fee value). if (b.alphaPips > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) revert InvalidFeeValue(); if (b.betaPips > MAX_BETA_PIPS) revert MultiplierTooLarge(); _feeBuckets.push(b); @@ -275,58 +258,39 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function setFamilyMultiplier(uint8 familyId, uint24 multiplierPips) external onlyFeeSetter { - if (familyId == 0) revert InvalidFamilyId(); - if (multiplierPips > MULTIPLIER_DENOMINATOR) revert MultiplierTooLarge(); - familyMultiplierPips[familyId] = multiplierPips; - emit FamilyMultiplierUpdated(familyId, multiplierPips); - } - - /// @inheritdoc IV4FeePolicy - function clearFamilyMultiplier(uint8 familyId) external onlyFeeSetter { - delete familyMultiplierPips[familyId]; - emit FamilyMultiplierUpdated(familyId, 0); - } - - /// @inheritdoc IV4FeePolicy - function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) - external - onlyFeeSetter - { + function setPairClassFee( + Currency currency0, + Currency currency1, + uint8 familyId, + uint24 feeValue + ) external onlyFeeSetter { if (currency0 >= currency1) revert CurrenciesOutOfOrder(); if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); uint24 stored = _encodeFee(feeValue); - pairFees[ph] = stored; - emit PairFeeUpdated(ph, stored); + pairClassFees[ph][familyId] = stored; + emit PairClassFeeUpdated(ph, familyId, stored); } /// @inheritdoc IV4FeePolicy - function clearPairFee(Currency currency0, Currency currency1) external onlyFeeSetter { + function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) + external + onlyFeeSetter + { if (currency0 >= currency1) revert CurrenciesOutOfOrder(); bytes32 ph = _pairHash(currency0, currency1); - delete pairFees[ph]; - emit PairFeeUpdated(ph, 0); + delete pairClassFees[ph][familyId]; + emit PairClassFeeUpdated(ph, familyId, 0); } // ─── Internal ─── /// @dev Returns true if the hook address has any RETURNS_DELTA flag set (bits 0-3). - /// This is a pure function of the address — the flags are baked into the address at - /// CREATE2 deployment time and cannot change. - /// @param hook The hook contract address to check. - /// @return True if any of the four RETURNS_DELTA bits are set. function _isCustomAccounting(address hook) internal pure returns (bool) { return uint160(hook) & CUSTOM_ACCOUNTING_MASK != 0; } - /// @dev Resolves the family ID for a hook using a priority chain: - /// 1. Governance override (hookFamilyId[hook]) — always wins if non-zero. - /// 2. Flag-rule match: gas-capped staticcall to protocolFeeFlags(), then walk - /// _flagRules in order. First rule whose requiredFlags are all present wins. - /// 3. Returns 0 (unclassified) if neither source provides a family. - /// @param hook The hook contract address to resolve. - /// @return The resolved family ID, or 0 if unclassified. + /// @dev Resolves the family ID for a hook: governance override, then flag rules. function _resolveFamily(address hook) internal view returns (uint8) { uint8 gov = hookFamilyId[hook]; if (gov != 0) return gov; @@ -356,29 +320,16 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return 0; } - /// @dev Computes a canonical hash for a token pair. Assumes c0 < c1 (guaranteed by - /// PoolKey sorting invariant). Used as the key for pairFees lookups. - /// @param c0 The lower currency address. - /// @param c1 The higher currency address. - /// @return The keccak256 hash of the packed currency addresses. + /// @dev Canonical hash for a sorted token pair. function _pairHash(Currency c0, Currency c1) internal pure returns (bytes32) { return keccak256(abi.encodePacked(Currency.unwrap(c0), Currency.unwrap(c1))); } - /// @dev Walks `_feeBuckets` backward to find the largest floor `<= lpFee`, evaluates - /// the piecewise-linear formula `alpha + beta * (lpFee - floor) / MULTIPLIER_DENOMINATOR` - /// per direction, clamps to MAX_PROTOCOL_FEE, and packs symmetrically into both 12-bit - /// components. Snap behavior: when `lpFee < floor_0`, the loop never breaks and the - /// pre-loop default of `_feeBuckets[0]` applies, with `delta = 0`, so the result is - /// `alpha_0` — the de facto minimum fee for very-low-LP-fee pools. - /// @param lpFee The pool's LP fee in pips (from key.fee). - /// @return The packed protocol fee with both 12-bit components equal. + /// @dev Piecewise-linear fee from `_feeBuckets` for StaticNativeMath pools. function _computeStaticNativeMathFee(uint24 lpFee) internal view returns (uint24) { uint256 len = _feeBuckets.length; if (len == 0) return 0; - // Default to the lowest bucket so the snap case (lpFee < floor_0) falls out - // naturally below with delta = 0. FeeBucket memory bucket = _feeBuckets[0]; for (uint256 i = len; i > 1; --i) { FeeBucket memory candidate = _feeBuckets[i - 1]; @@ -397,38 +348,17 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return uint24((perDirection << 12) | perDirection); } - /// @dev Scales each 12-bit directional fee component by a pips multiplier. The two - /// 12-bit components are extracted, scaled independently, and repacked into a single - /// uint24. Shares MULTIPLIER_DENOMINATOR with `_computeStaticNativeMathFee`. No clamp - /// is needed: pairFees are validated <= MAX_PROTOCOL_FEE per direction at write time, - /// and `multiplierPips` is bounded by `setFamilyMultiplier` to <= 1_000_000. - /// @param baseFee The base protocol fee (two 12-bit directional components packed). - /// @param multiplierPips The multiplier in pips (max 1_000_000 = 100%). - /// @return The scaled protocol fee. - function _applyMultiplier(uint24 baseFee, uint24 multiplierPips) internal pure returns (uint24) { - uint256 fee0 = uint256(baseFee & 0xFFF) * multiplierPips / MULTIPLIER_DENOMINATOR; - uint256 fee1 = uint256(baseFee >> 12) * multiplierPips / MULTIPLIER_DENOMINATOR; - return uint24((fee1 << 12) | fee0); - } - - /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in - /// storage means "not set" rather than "explicitly zero". - /// @param feeValue The actual fee value (0 = explicit zero). - /// @return The encoded value to store. + /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL. function _encodeFee(uint24 feeValue) internal pure returns (uint24) { return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; } - /// @dev Decodes a fee from storage. Converts ZERO_FEE_SENTINEL back to 0. - /// @param stored The raw value from storage. - /// @return The actual fee value. + /// @dev Decodes a fee from storage. function _decodeFee(uint24 stored) internal pure returns (uint24) { return stored == ZERO_FEE_SENTINEL ? 0 : stored; } - /// @dev Validates that a protocol fee is within v4-core bounds (each 12-bit directional - /// component must be <= MAX_PROTOCOL_FEE = 1000). - /// @param feeValue The fee to validate. + /// @dev Validates protocol fee bounds. function _validateFee(uint24 feeValue) internal pure { if (!ProtocolFeeLibrary.isValidProtocolFee(feeValue)) revert InvalidFeeValue(); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 24cf6fd0..0b534743 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -35,13 +35,13 @@ struct FlagRule { /// @title IV4FeePolicy /// @notice Interface for the V4 fee policy contract that computes protocol fees based on /// automated hook classification and governance-configured parameters. -/// @dev Hook family IDs are governance-assigned uint8 values (1-255). 0 = unclassified. -/// Family IDs have no hardcoded semantic meaning — labels live in offchain documentation. +/// @dev Hook family IDs are governance-assigned uint8 values (1-255). 0 = unclassified hook +/// on the classified path. `NATIVE_MATH_FAMILY_ID` (0) in `pairClassFees` is a reserved +/// slot for StaticNativeMath pair overrides only — not a classified hook family. /// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); /// governance-configured flag rules map flag patterns to families automatically. -/// Static NativeMath pools bypass classification and derive their protocol fee from a -/// piecewise-linear schedule of fee buckets keyed by LP-fee floor (per-direction, -/// clamped to MAX_PROTOCOL_FEE). +/// Static NativeMath pools bypass hook classification and derive their protocol fee from +/// `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]` or a piecewise-linear fee-bucket schedule. /// Custom-accounting hooks and dynamic fee pools require classification (governance /// override, flag rule match, or defaultFee fallback). /// @custom:security-contact security@uniswap.org @@ -57,11 +57,10 @@ interface IV4FeePolicy { /// @notice Thrown when familyId == 0 is passed to a function that requires > 0. error InvalidFamilyId(); - /// @notice Thrown when a multiplier exceeds its allowed bound: `familyMultiplierPips` - /// > 1_000_000, or a fee bucket's `betaPips` > 1_000_000_000. + /// @notice Thrown when a fee bucket's `betaPips` exceeds 1_000_000_000. error MultiplierTooLarge(); - /// @notice Thrown when currency0 >= currency1 in setPairFee. + /// @notice Thrown when currency0 >= currency1 in setPairClassFee. error CurrenciesOutOfOrder(); /// @notice Thrown when a flag rule has requiredFlags == 0 or familyId == 0. @@ -101,17 +100,14 @@ interface IV4FeePolicy { /// @param feeValue The new encoded default fee. event FamilyDefaultUpdated(uint8 indexed familyId, uint24 feeValue); - /// @notice Emitted when a family's multiplier is updated. - /// @param familyId The family whose multiplier was changed. - /// @param multiplierPips The new multiplier in pips (0 = removed, 1_000_000 = 100%). - event FamilyMultiplierUpdated(uint8 indexed familyId, uint24 multiplierPips); - - /// @notice Emitted when a pair fee is updated. + /// @notice Emitted when a pair class fee is updated. /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, - /// ZERO_FEE_SENTINEL = explicit zero fee. + /// ZERO_FEE_SENTINEL = explicit zero fee. `familyId` is `NATIVE_MATH_FAMILY_ID` for + /// StaticNativeMath pair overrides, or a classified family ID (1-255) otherwise. /// @param pairHash The canonical hash of the token pair. - /// @param feeValue The new encoded pair fee. - event PairFeeUpdated(bytes32 indexed pairHash, uint24 feeValue); + /// @param familyId The family slot that was changed. + /// @param feeValue The new encoded fee. + event PairClassFeeUpdated(bytes32 indexed pairHash, uint8 indexed familyId, uint24 feeValue); /// @notice Emitted when the fee buckets array is replaced. /// @param bucketCount The number of buckets in the new array. @@ -135,6 +131,11 @@ interface IV4FeePolicy { /// @return The bitmask value (0xF). function CUSTOM_ACCOUNTING_MASK() external pure returns (uint160); + /// @notice Reserved `pairClassFees` family slot for StaticNativeMath pair overrides. + /// @dev Not a classified hook family. Distinct from `hookFamilyId == 0` (unclassified). + /// @return 0 + function NATIVE_MATH_FAMILY_ID() external pure returns (uint8); + // --- Immutables --- /// @notice The Uniswap V4 PoolManager this policy reads state from. @@ -147,36 +148,29 @@ interface IV4FeePolicy { /// @return The current fee setter address. function feeSetter() external view returns (address); - /// @notice Fallback fee for all classified pools when no family-specific config applies. + /// @notice Fallback fee for classified pools when no family-specific config applies. /// @dev Also used for unclassified hooks (familyId == 0). Sentinel-encoded in storage. /// @return The sentinel-encoded default fee. function defaultFee() external view returns (uint24); /// @notice Returns the governance-assigned family ID for a hook. - /// @dev 0 = unclassified. StaticNativeMath pools bypass this entirely. + /// @dev 0 = unclassified on the classified path. StaticNativeMath pools do not use this. /// @param hook The hook address to query. /// @return The family ID (0-255). function hookFamilyId(address hook) external view returns (uint8); /// @notice Returns the default protocol fee for a given family ID. - /// @param familyId The family to query. + /// @param familyId The family to query (must be > 0 when set via admin). /// @return The sentinel-encoded default fee for the family. function familyDefaults(uint8 familyId) external view returns (uint24); - /// @notice Returns the multiplier (in pips) for a given family ID. - /// @dev 1_000_000 = 100% (1x), 500_000 = 50% (0.5x). Applied to pairFees to derive a - /// scaled fee on the classified path. Shares the same denominator - /// (MULTIPLIER_DENOMINATOR = 1_000_000) as the StaticNativeMath bucket schedule. - /// @param familyId The family to query. - /// @return The multiplier in pips (0 = not set). - function familyMultiplierPips(uint8 familyId) external view returns (uint24); - - /// @notice Returns the pair fee for a token pair hash. - /// @dev Flat mapping — one fee per pair. StaticNativeMath uses it directly (overrides - /// the bucket schedule). Classified pools scale it by the family multiplier. + /// @notice Returns the pair class fee for a token pair and family slot. + /// @dev `familyId == NATIVE_MATH_FAMILY_ID` is the StaticNativeMath pair override. + /// Classified pools use family IDs 1-255. 0 in storage = not set. /// @param pairHash The canonical keccak256 hash of the sorted token pair. - /// @return The sentinel-encoded pair fee (0 = not set). - function pairFees(bytes32 pairHash) external view returns (uint24); + /// @param familyId The family slot to query. + /// @return The sentinel-encoded fee (0 = not set). + function pairClassFees(bytes32 pairHash, uint8 familyId) external view returns (uint24); /// @notice Returns the number of fee buckets configured. /// @return The count of fee buckets. @@ -213,15 +207,9 @@ interface IV4FeePolicy { // --- Fee Computation --- /// @notice Computes the protocol fee for a pool. - /// @dev Three paths: - /// 1. StaticNativeMath (no return-delta flags, static fee): pair fee, or evaluate the - /// fee-bucket schedule — find the bucket with the largest `lpFeeFloor <= key.fee` - /// (snap to bucket 0 if `key.fee < floor_0`) and return - /// `alpha + beta * (key.fee - floor) / 1_000_000` per direction (clamped to - /// MAX_PROTOCOL_FEE, packed symmetrically). - /// 2. Dynamic fee NativeMath: requires governance familyId (Slot0.lpFee is unreliable). - /// 3. CustomAccounting (return-delta flags set): requires governance familyId. - /// Paths 2 and 3 fall through to defaultFee if unclassified. + /// @dev StaticNativeMath: `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]` or fee buckets. + /// Classified: resolve family → `pairClassFees[pair][family]` → `familyDefaults[family]` + /// → `defaultFee`. Unclassified (family 0) → `defaultFee` only. /// Callable by anyone (no access control) for offchain tooling. /// @param key The pool key to compute the fee for. /// @return fee The computed protocol fee (two 12-bit directional components packed). @@ -256,7 +244,7 @@ interface IV4FeePolicy { // --- Default Fee (onlyFeeSetter) --- - /// @notice Sets the fallback fee for all classified pools (including unclassified hooks). + /// @notice Sets the fallback fee for classified pools (including unclassified hooks). /// @dev Setting 0 sets an explicit zero fee. Use clearDefaultFee to remove entirely. /// @param feeValue The protocol fee to set. Must pass isValidProtocolFee if non-zero. function setDefaultFee(uint24 feeValue) external; @@ -276,11 +264,10 @@ interface IV4FeePolicy { /// @param buckets The new fee buckets, ordered ascending by lpFeeFloor. function setFeeBuckets(FeeBucket[] calldata buckets) external; - /// @notice Removes all fee buckets. The StaticNativeMath path then returns 0 for any - /// pool that has no pair-fee override. + /// @notice Removes all fee buckets. StaticNativeMath returns 0 when no pair class fee is set. function clearFeeBuckets() external; - // --- Family Defaults & Multipliers (onlyFeeSetter) --- + // --- Family Defaults (onlyFeeSetter) --- /// @notice Sets the default protocol fee for a given family ID. /// @dev familyId must be > 0. Setting 0 sets explicit zero. Use clearFamilyDefault to @@ -293,32 +280,26 @@ interface IV4FeePolicy { /// @param familyId The family to clear. function clearFamilyDefault(uint8 familyId) external; - /// @notice Sets a multiplier for a family, applied to pairFees on the classified path. - /// @dev familyId must be > 0. multiplierPips in pips (1_000_000 = 100% = 1x). - /// Reverts MultiplierTooLarge if `multiplierPips > 1_000_000`. - /// @param familyId The family to configure. - /// @param multiplierPips The multiplier in pips (max 1_000_000 = 100%). - function setFamilyMultiplier(uint8 familyId, uint24 multiplierPips) external; + // --- Pair Class Fees (onlyFeeSetter) --- - /// @notice Removes the multiplier for a family. - /// @param familyId The family to clear. - function clearFamilyMultiplier(uint8 familyId) external; - - // --- Pair Fees (onlyFeeSetter) --- - - /// @notice Sets the pair fee for a token pair. - /// @dev StaticNativeMath pools use this directly (overrides the fee buckets). - /// Classified pools scale it by familyMultiplierPips. If a nonzero pair fee scales to - /// zero because of integer truncation, the classified path falls through to the family - /// default. Setting 0 sets explicit zero and does not fall through. Use clearPairFee to - /// remove entirely. + /// @notice Sets the pair class fee for a token pair and family slot. + /// @dev Use `NATIVE_MATH_FAMILY_ID` for StaticNativeMath pair overrides (Path A). + /// Use family IDs 1-255 for classified pools (Path B). Setting 0 sets explicit zero + /// and does not fall through. Use clearPairClassFee to remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. - /// @param feeValue The pair fee. Must pass isValidProtocolFee if non-zero. - function setPairFee(Currency currency0, Currency currency1, uint24 feeValue) external; - - /// @notice Removes the pair fee, falling through to the fee buckets. + /// @param familyId The family slot (`NATIVE_MATH_FAMILY_ID` or 1-255). + /// @param feeValue The protocol fee. Must pass isValidProtocolFee if non-zero. + function setPairClassFee( + Currency currency0, + Currency currency1, + uint8 familyId, + uint24 feeValue + ) external; + + /// @notice Removes the pair class fee for a family slot. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. - function clearPairFee(Currency currency0, Currency currency1) external; + /// @param familyId The family slot to clear. + function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) external; } diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol index e55a86b7..130141ca 100644 --- a/test/V4FeeAdapter.fork.t.sol +++ b/test/V4FeeAdapter.fork.t.sol @@ -192,18 +192,17 @@ contract V4FeeAdapterForkTest is Deployers { assertEq(fee500, PROTO_FEE_100); } - // ============ Pair Fee Overrides Multiplier ============ + // ============ Native Pair Class Fee Overrides Buckets ============ - function test_pairFee_overridesMultiplier() public { + function test_nativePairClassFee_overridesBuckets() public { vm.startPrank(feeSetter); - // Any non-zero multiplier — pair fee should win regardless policy.setFeeBuckets(_singleBucketSlope(100_000)); - policy.setPairFee(currency0, currency1, PROTO_FEE_300); + policy.setPairClassFee(currency0, currency1, policy.NATIVE_MATH_FAMILY_ID(), PROTO_FEE_300); vm.stopPrank(); adapter.triggerFeeUpdate(pool3000); - // Pair fee takes precedence over the multiplier + // Native pair class fee takes precedence over the bucket schedule (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); assertEq(fee, PROTO_FEE_300); } diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 4c708edb..cc28123d 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -613,15 +613,17 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(lowFeeKey), expected); } - function test_computeFee_staticNativeMath_pairFeeOverridesBuckets() public { + function test_computeFee_staticNativeMath_pairClassFeeOverridesBuckets() public { vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); - policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); vm.stopPrank(); - // Pair fee should override the bucket-derived fee + // Native pair class fee should override the bucket-derived fee assertEq(policy.computeFee(standardKey), FEE_500); - vm.snapshotGasLastCall("policy.computeFee - static native math pair fee"); + vm.snapshotGasLastCall("policy.computeFee - static native math pair class fee"); } function test_computeFee_staticNativeMath_zeroBucketsReturnsZero() public view { @@ -784,13 +786,15 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(k), (100 << 12) | 100); } - function test_computeFee_staticNativeMath_pairFeeBeatsBuckets() public { + function test_computeFee_staticNativeMath_pairClassFeeBeatsBuckets() public { vm.startPrank(feeSetter); policy.setFeeBuckets(_bucketsConfigB()); - policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_200); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_200 + ); vm.stopPrank(); - // Pair fee wins over bucket-derived fee + // Native pair class fee wins over bucket-derived fee assertEq(policy.computeFee(standardKey), FEE_200); } @@ -846,7 +850,7 @@ contract V4FeeAdapterTest is Test { vm.snapshotGasLastCall("policy.computeFee - classified family default"); } - function test_computeFee_classified_pairFeeTimesMultiplier() public { + function test_computeFee_classified_pairClassFeeLiteral() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ currency0: Currency.wrap(address(token0)), @@ -859,16 +863,14 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); - policy.setPairFee(customKey.currency0, customKey.currency1, FEE_200); - policy.setFamilyMultiplier(1, 500_000); // 50% + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_100); vm.stopPrank(); - // FEE_200 = 200|200, multiplied by 50% = 100|100 = FEE_100 assertEq(policy.computeFee(customKey), FEE_100); - vm.snapshotGasLastCall("policy.computeFee - classified pairFee * multiplier"); + vm.snapshotGasLastCall("policy.computeFee - classified pair class fee"); } - function test_computeFee_classified_roundingToZeroFallsBackToFamilyDefault() public { + function test_computeFee_classified_clearPairClassFeeFallsBackToFamilyDefault() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ currency0: Currency.wrap(address(token0)), @@ -883,15 +885,42 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); - policy.setPairFee(customKey.currency0, customKey.currency1, FEE_100); - policy.setFamilyMultiplier(1, 9999); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_100); policy.setFamilyDefault(1, fee50); vm.stopPrank(); + assertEq(policy.computeFee(customKey), FEE_100); + + vm.prank(feeSetter); + policy.clearPairClassFee(customKey.currency0, customKey.currency1, 1); + assertEq(policy.computeFee(customKey), fee50); } - function test_computeFee_classified_explicitZeroPairFeeDoesNotFallBack() public { + function test_computeFee_classified_nativePairClassFeeDoesNotApply() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setPairClassFee( + customKey.currency0, customKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + // NATIVE_MATH_FAMILY_ID slot is not read on the classified path + assertEq(policy.computeFee(customKey), FEE_200); + } + + function test_computeFee_classified_explicitZeroPairClassFeeDoesNotFallBack() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ currency0: Currency.wrap(address(token0)), @@ -904,15 +933,14 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); - policy.setPairFee(customKey.currency0, customKey.currency1, 0); - policy.setFamilyMultiplier(1, 500_000); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, 0); policy.setFamilyDefault(1, FEE_200); vm.stopPrank(); assertEq(policy.computeFee(customKey), 0); } - function test_computeFee_classified_pairFeeAtMaxMultiplier() public { + function test_computeFee_classified_pairClassFeeAtMax() public { address customHook = address(uint160((1 << 7) | (1 << 2))); PoolKey memory customKey = PoolKey({ currency0: Currency.wrap(address(token0)), @@ -925,12 +953,9 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setHookFamily(customHook, 1); - policy.setPairFee(customKey.currency0, customKey.currency1, FEE_1000); - policy.setFamilyMultiplier(1, 1_000_000); // 100% (1x) -- the new ceiling + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_1000); vm.stopPrank(); - // FEE_1000 (1000|1000) * 1x = FEE_1000; setter validation guarantees the result - // can never exceed MAX_PROTOCOL_FEE per direction. assertEq(policy.computeFee(customKey), FEE_1000); } @@ -1187,53 +1212,29 @@ contract V4FeeAdapterTest is Test { policy.setFamilyDefault(0, FEE_100); } - function test_setFamilyMultiplier_success() public { - vm.expectEmit(true, false, false, true, address(policy)); - emit IV4FeePolicy.FamilyMultiplierUpdated(2, 500_000); - vm.prank(feeSetter); - policy.setFamilyMultiplier(2, 500_000); - vm.snapshotGasLastCall("policy.setFamilyMultiplier"); - assertEq(policy.familyMultiplierPips(2), 500_000); - } - - function test_setFamilyMultiplier_revertsZeroFamily() public { - vm.prank(feeSetter); - vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); - policy.setFamilyMultiplier(0, 100_000); - } - - function test_setFamilyMultiplier_revertsTooLarge() public { - vm.prank(feeSetter); - vm.expectRevert(IV4FeePolicy.MultiplierTooLarge.selector); - policy.setFamilyMultiplier(1, 1_000_001); - } - - function test_setFamilyMultiplier_acceptsBoundary() public { - vm.prank(feeSetter); - policy.setFamilyMultiplier(1, 1_000_000); - assertEq(policy.familyMultiplierPips(1), 1_000_000); - } - - function test_setPairFee_success() public { + function test_setPairClassFee_success() public { bytes32 ph = _pairHash(); - vm.expectEmit(true, false, false, true, address(policy)); - emit IV4FeePolicy.PairFeeUpdated(ph, FEE_200); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, FEE_200); vm.prank(feeSetter); - policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_200); - vm.snapshotGasLastCall("policy.setPairFee"); - assertEq(policy.pairFees(ph), FEE_200); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_200); + vm.snapshotGasLastCall("policy.setPairClassFee"); + assertEq(policy.pairClassFees(ph, nativeFamily), FEE_200); } - function test_setPairFee_revertsCurrenciesOutOfOrder() public { - vm.prank(feeSetter); + function test_setPairClassFee_revertsCurrenciesOutOfOrder() public { vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); - policy.setPairFee(standardKey.currency1, standardKey.currency0, FEE_200); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency1, standardKey.currency0, 0, FEE_200); } - function test_setPairFee_revertsInvalidFee() public { - vm.prank(feeSetter); + function test_setPairClassFee_revertsInvalidFee() public { vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); - policy.setPairFee(standardKey.currency0, standardKey.currency1, (1001 << 12) | 500); + vm.prank(feeSetter); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, 0, (1001 << 12) | 500 + ); } // ============ Policy: Sentinel Encoding ============ @@ -1289,13 +1290,15 @@ contract V4FeeAdapterTest is Test { emit IV4FeePolicy.FamilyDefaultUpdated(1, 0); policy.clearFamilyDefault(1); - vm.expectEmit(true, false, false, true, address(policy)); - emit IV4FeePolicy.PairFeeUpdated(ph, type(uint24).max); - policy.setPairFee(standardKey.currency0, standardKey.currency1, 0); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); - vm.expectEmit(true, false, false, true, address(policy)); - emit IV4FeePolicy.PairFeeUpdated(ph, 0); - policy.clearPairFee(standardKey.currency0, standardKey.currency1); + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, type(uint24).max); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, 0); + + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, 0); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily); vm.stopPrank(); } @@ -1339,36 +1342,27 @@ contract V4FeeAdapterTest is Test { vm.stopPrank(); } - function test_clearFamilyMultiplier() public { - vm.startPrank(feeSetter); - policy.setFamilyMultiplier(1, 500_000); - assertEq(policy.familyMultiplierPips(1), 500_000); - - policy.clearFamilyMultiplier(1); - assertEq(policy.familyMultiplierPips(1), 0); - vm.stopPrank(); - } - - function test_clearPairFee_fallsThroughToMultiplier() public { + function test_clearPairClassFee_fallsThroughToBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); - policy.setPairFee(standardKey.currency0, standardKey.currency1, FEE_500); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_500); vm.stopPrank(); - assertEq(policy.computeFee(standardKey), FEE_500); // pair fee wins + assertEq(policy.computeFee(standardKey), FEE_500); // pair class fee wins vm.prank(feeSetter); - policy.clearPairFee(standardKey.currency0, standardKey.currency1); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily); - assertEq(policy.pairFees(_pairHash()), 0); // storage deleted - // Falls through to multiplier: 3000 * 100_000 / 1_000_000 = 300 + assertEq(policy.pairClassFees(_pairHash(), nativeFamily), 0); // storage deleted + // Falls through to buckets: 3000 * 100_000 / 1_000_000 = 300 assertEq(policy.computeFee(standardKey), FEE_300); } - function test_clearPairFee_revertsCurrenciesOutOfOrder() public { - vm.prank(feeSetter); + function test_clearPairClassFee_revertsCurrenciesOutOfOrder() public { vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); - policy.clearPairFee(standardKey.currency1, standardKey.currency0); + vm.prank(feeSetter); + policy.clearPairClassFee(standardKey.currency1, standardKey.currency0, 0); } // ============ Integration: Full Waterfall ============ @@ -1389,14 +1383,16 @@ contract V4FeeAdapterTest is Test { policy.setDefaultFee(FEE_100); policy.setHookFamily(customHook, 1); policy.setFamilyDefault(1, FEE_200); - policy.setPairFee(customKey.currency0, customKey.currency1, FEE_300); - policy.setFamilyMultiplier(1, 1_000_000); // 1x + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_300); vm.stopPrank(); - // StandardKey -> StaticNativeMath -> pair fee overrides multiplier -> FEE_300 - assertEq(adapter.getFee(standardKey), FEE_300); + // StandardKey -> StaticNativeMath -> native pair class fee -> FEE_500 + assertEq(adapter.getFee(standardKey), FEE_500); - // CustomKey -> Classified -> pair fee × multiplier -> FEE_300 × 1x = FEE_300 + // CustomKey -> Classified -> pair class fee -> FEE_300 assertEq(adapter.getFee(customKey), FEE_300); // Pool override beats everything @@ -1779,7 +1775,7 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(key), FEE_200); } - function test_flagRule_withPairFeeAndMultiplier() public { + function test_flagRule_withPairClassFee() public { uint160 addrFlags = (1 << 7) | (1 << 2); address hookAddr = address(addrFlags); uint256 feeFlags = HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS; @@ -1802,11 +1798,9 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFlagRules(rules); - policy.setPairFee(key.currency0, key.currency1, FEE_200); - policy.setFamilyMultiplier(3, 500_000); // 50% + policy.setPairClassFee(key.currency0, key.currency1, 3, FEE_100); vm.stopPrank(); - // FEE_200 (200|200) * 50% = (100|100) = FEE_100 assertEq(policy.computeFee(key), FEE_100); } From f99c3cd3ea3056834ac7675603a776a332db3083 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Wed, 27 May 2026 15:08:42 -0400 Subject: [PATCH 17/26] feat(v4): add batch hook family and pair class fee setters Expose batchSetHookFamily, batchSetPairClassFee, and batchClearPairClassFee so governance can configure many hooks or pairs in one transaction. --- snapshots/V4FeeAdapterTest.json | 17 ++++---- src/feeAdapters/V4FeePolicy.sol | 74 ++++++++++++++++++++++++++++----- src/interfaces/IV4FeePolicy.sol | 33 +++++++++++++++ test/V4FeeAdapter.t.sol | 72 +++++++++++++++++++++++++++++++- 4 files changed, 178 insertions(+), 18 deletions(-) diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index b6b5e133..e319ed04 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -4,6 +4,9 @@ "adapter.getFee - pool override hit": "1330", "adapter.setPoolOverride": "26353", "adapter.triggerFeeUpdate - single pool": "27030", + "policy.batchClearPairClassFee": "6693", + "policy.batchSetHookFamily": "50820", + "policy.batchSetPairClassFee": "53162", "policy.computeFee - classified family default": "3947", "policy.computeFee - classified flag-rule self-report": "6956", "policy.computeFee - classified griefing hook -> defaultFee": "5722", @@ -13,11 +16,11 @@ "policy.computeFee - flag-rule single flag match": "6956", "policy.computeFee - static native math buckets": "4832", "policy.computeFee - static native math pair class fee": "1649", - "policy.setFamilyDefault": "26491", - "policy.setFeeBuckets - 1 bucket": "49650", - "policy.setFeeBuckets - 5 buckets (configuration B)": "125106", - "policy.setFlagRules - 32 rules (max)": "1469583", - "policy.setFlagRules - two rules": "116223", - "policy.setHookFamily": "26223", - "policy.setPairClassFee": "27283" + "policy.setFamilyDefault": "26469", + "policy.setFeeBuckets - 1 bucket": "49769", + "policy.setFeeBuckets - 5 buckets (configuration B)": "125225", + "policy.setFlagRules - 32 rules (max)": "1469615", + "policy.setFlagRules - two rules": "116255", + "policy.setHookFamily": "26257", + "policy.setPairClassFee": "27349" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index f3731a95..6fff8c3c 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -8,7 +8,14 @@ import {Currency} from "v4-core/types/Currency.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {LibBit} from "solady/src/utils/LibBit.sol"; -import {IV4FeePolicy, FlagRule, FeeBucket} from "../interfaces/IV4FeePolicy.sol"; +import { + IV4FeePolicy, + FlagRule, + FeeBucket, + HookFamilyAssignment, + PairClassFeeAssignment, + PairClassFeeClear +} from "../interfaces/IV4FeePolicy.sol"; import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @title V4FeePolicy @@ -172,8 +179,16 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function setHookFamily(address hook, uint8 familyId) external onlyFeeSetter { - hookFamilyId[hook] = familyId; - emit HookFamilySet(hook, familyId); + _setHookFamily(hook, familyId); + } + + /// @inheritdoc IV4FeePolicy + function batchSetHookFamily(HookFamilyAssignment[] calldata assignments) external onlyFeeSetter { + uint256 len = assignments.length; + for (uint256 i; i < len; ++i) { + HookFamilyAssignment calldata a = assignments[i]; + _setHookFamily(a.hook, a.familyId); + } } /// @inheritdoc IV4FeePolicy @@ -264,6 +279,51 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint8 familyId, uint24 feeValue ) external onlyFeeSetter { + _setPairClassFee(currency0, currency1, familyId, feeValue); + } + + /// @inheritdoc IV4FeePolicy + function batchSetPairClassFee(PairClassFeeAssignment[] calldata assignments) + external + onlyFeeSetter + { + uint256 len = assignments.length; + for (uint256 i; i < len; ++i) { + PairClassFeeAssignment calldata a = assignments[i]; + _setPairClassFee(a.currency0, a.currency1, a.familyId, a.feeValue); + } + } + + /// @inheritdoc IV4FeePolicy + function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) + external + onlyFeeSetter + { + _clearPairClassFee(currency0, currency1, familyId); + } + + /// @inheritdoc IV4FeePolicy + function batchClearPairClassFee(PairClassFeeClear[] calldata clears) external onlyFeeSetter { + uint256 len = clears.length; + for (uint256 i; i < len; ++i) { + PairClassFeeClear calldata c = clears[i]; + _clearPairClassFee(c.currency0, c.currency1, c.familyId); + } + } + + // ─── Internal ─── + + function _setHookFamily(address hook, uint8 familyId) internal { + hookFamilyId[hook] = familyId; + emit HookFamilySet(hook, familyId); + } + + function _setPairClassFee( + Currency currency0, + Currency currency1, + uint8 familyId, + uint24 feeValue + ) internal { if (currency0 >= currency1) revert CurrenciesOutOfOrder(); if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); @@ -272,19 +332,13 @@ contract V4FeePolicy is IV4FeePolicy, Owned { emit PairClassFeeUpdated(ph, familyId, stored); } - /// @inheritdoc IV4FeePolicy - function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) - external - onlyFeeSetter - { + function _clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) internal { if (currency0 >= currency1) revert CurrenciesOutOfOrder(); bytes32 ph = _pairHash(currency0, currency1); delete pairClassFees[ph][familyId]; emit PairClassFeeUpdated(ph, familyId, 0); } - // ─── Internal ─── - /// @dev Returns true if the hook address has any RETURNS_DELTA flag set (bits 0-3). function _isCustomAccounting(address hook) internal pure returns (bool) { return uint160(hook) & CUSTOM_ACCOUNTING_MASK != 0; diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 0b534743..83021531 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -32,6 +32,27 @@ struct FlagRule { uint8 familyId; } +/// @dev Hook address and family for `batchSetHookFamily`. +struct HookFamilyAssignment { + address hook; + uint8 familyId; +} + +/// @dev Token pair, family slot, and fee for `batchSetPairClassFee`. +struct PairClassFeeAssignment { + Currency currency0; + Currency currency1; + uint8 familyId; + uint24 feeValue; +} + +/// @dev Token pair and family slot for `batchClearPairClassFee`. +struct PairClassFeeClear { + Currency currency0; + Currency currency1; + uint8 familyId; +} + /// @title IV4FeePolicy /// @notice Interface for the V4 fee policy contract that computes protocol fees based on /// automated hook classification and governance-configured parameters. @@ -229,6 +250,10 @@ interface IV4FeePolicy { /// @param familyId The family ID to assign (0 = unclassify). function setHookFamily(address hook, uint8 familyId) external; + /// @notice Assigns multiple hooks to families in one transaction. + /// @param assignments Hook/family pairs. Emits `HookFamilySet` per entry. + function batchSetHookFamily(HookFamilyAssignment[] calldata assignments) external; + // --- Flag Rules (onlyFeeSetter) --- /// @notice Replaces the entire flag rules array atomically. @@ -297,9 +322,17 @@ interface IV4FeePolicy { uint24 feeValue ) external; + /// @notice Sets multiple pair class fees in one transaction. + /// @param assignments Pair/family/fee tuples. Emits `PairClassFeeUpdated` per entry. + function batchSetPairClassFee(PairClassFeeAssignment[] calldata assignments) external; + /// @notice Removes the pair class fee for a family slot. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. /// @param familyId The family slot to clear. function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) external; + + /// @notice Clears multiple pair class fees in one transaction. + /// @param clears Pair/family slots to remove. Emits `PairClassFeeUpdated` with fee 0 per entry. + function batchClearPairClassFee(PairClassFeeClear[] calldata clears) external; } diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index cc28123d..c40c879c 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -13,7 +13,13 @@ import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; -import {FlagRule, FeeBucket} from "../src/interfaces/IV4FeePolicy.sol"; +import { + FlagRule, + FeeBucket, + HookFamilyAssignment, + PairClassFeeAssignment, + PairClassFeeClear +} from "../src/interfaces/IV4FeePolicy.sol"; import {HookFeeFlags} from "../src/libraries/HookFeeFlags.sol"; import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { @@ -1184,6 +1190,22 @@ contract V4FeeAdapterTest is Test { policy.setHookFamily(address(1), 1); } + function test_batchSetHookFamily_success() public { + address hook0 = address(uint160(1 << 2)); + address hook1 = address(uint160((1 << 7) | (1 << 2))); + + HookFamilyAssignment[] memory assignments = new HookFamilyAssignment[](2); + assignments[0] = HookFamilyAssignment({hook: hook0, familyId: 1}); + assignments[1] = HookFamilyAssignment({hook: hook1, familyId: 3}); + + vm.prank(feeSetter); + policy.batchSetHookFamily(assignments); + vm.snapshotGasLastCall("policy.batchSetHookFamily"); + + assertEq(policy.hookFamilyId(hook0), 1); + assertEq(policy.hookFamilyId(hook1), 3); + } + function test_setDefaultFee_success() public { vm.expectEmit(false, false, false, true, address(policy)); emit IV4FeePolicy.DefaultFeeUpdated(FEE_100); @@ -1237,6 +1259,54 @@ contract V4FeeAdapterTest is Test { ); } + function test_batchSetPairClassFee_success() public { + bytes32 ph = _pairHash(); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + PairClassFeeAssignment[] memory assignments = new PairClassFeeAssignment[](2); + assignments[0] = PairClassFeeAssignment({ + currency0: standardKey.currency0, + currency1: standardKey.currency1, + familyId: nativeFamily, + feeValue: FEE_200 + }); + assignments[1] = PairClassFeeAssignment({ + currency0: standardKey.currency0, + currency1: standardKey.currency1, + familyId: 1, + feeValue: FEE_300 + }); + + vm.prank(feeSetter); + policy.batchSetPairClassFee(assignments); + vm.snapshotGasLastCall("policy.batchSetPairClassFee"); + + assertEq(policy.pairClassFees(ph, nativeFamily), FEE_200); + assertEq(policy.pairClassFees(ph, 1), FEE_300); + } + + function test_batchClearPairClassFee_success() public { + bytes32 ph = _pairHash(); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_500); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, FEE_300); + + PairClassFeeClear[] memory clears = new PairClassFeeClear[](2); + clears[0] = + PairClassFeeClear({currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: nativeFamily}); + clears[1] = + PairClassFeeClear({currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: 1}); + + policy.batchClearPairClassFee(clears); + vm.snapshotGasLastCall("policy.batchClearPairClassFee"); + vm.stopPrank(); + + assertEq(policy.pairClassFees(ph, nativeFamily), 0); + assertEq(policy.pairClassFees(ph, 1), 0); + } + // ============ Policy: Sentinel Encoding ============ function test_sentinel_setZeroIsExplicitZero() public { From a10860cdbea5af17f38ab3d7edc0f06acba3db3b Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 11:38:16 -0400 Subject: [PATCH 18/26] feat(v4): unify fee resolution with native-math family sentinel Route all pools through _resolveFamily and a single computeFee waterfall. Use NATIVE_MATH_FAMILY_ID (255) for static pools so unclassified (0) no longer collides. Governance can opt static hooks into classified fees via setHookFamily. --- README.md | 4 +- snapshots/V4FeeAdapterForkTest.json | 4 +- snapshots/V4FeeAdapterTest.json | 36 ++-- src/feeAdapters/V4FeePolicy.sol | 77 ++++---- src/interfaces/IV4FeePolicy.sol | 48 ++--- test/V4FeeAdapter.fork.t.sol | 64 +++++++ test/V4FeeAdapter.t.sol | 265 +++++++++++++++++++++++++++- 7 files changed, 406 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 1735a180..d73d7cb8 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA Path A's fee buckets are an ascending-by-`lpFeeFloor` array (max 16) of `(lpFeeFloor, alpha, beta)` triples. Each bucket's `alpha` is a flat per-direction base fee (≤ `MAX_PROTOCOL_FEE = 1000`), and `beta` is a slope in pips per pip of `(lpFee - floor)` (≤ 1_000_000_000). Setting `beta = 0` yields a pure step function; `alpha = 0` yields a slope-only multiplier; both nonzero yields a piecewise-linear curve. Continuity at boundaries is governance's responsibility — the contract does not enforce it. The lowest bucket's `alpha` doubles as a minimum-fee floor for very-low-LP-fee pools, since `key.fee < floor_0` snaps to bucket 0 with `delta = 0`. -Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) for bucket slope math. Bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts bucket setters that exceed that cap. `NATIVE_MATH_FAMILY_ID` (0) in `pairClassFees` is reserved for StaticNativeMath pair overrides — distinct from `hookFamilyId == 0` (unclassified hook). +Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) for bucket slope math. Bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts bucket setters that exceed that cap. `NATIVE_MATH_FAMILY_ID` (255) in `pairClassFees` is reserved for native-math pair overrides. Governance families are 1-255; `hookFamilyId == 0` is unclassified. `familyId` resolution for Path B: @@ -151,7 +151,7 @@ Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1 2. gas-capped staticcall to `hook.protocolFeeFlags()` (optional `IFeeClassifiedHook` interface) → walk governance-configured `flagRules` first-match-wins 3. otherwise unclassified → falls through to `defaultFee` -With a non-zero family, the policy returns `pairClassFees[ph][family]` if set, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair class fee still short-circuits to zero. Unclassified hooks (`family == 0`) use `defaultFee` only. +With a governance family (1-255), the policy returns `pairClassFees[ph][family]` if set, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair class fee still short-circuits to zero. Unclassified hooks (`family == 0`) use `defaultFee` only. Static pools resolve to `NATIVE_MATH_FAMILY_ID` (255) and use pair overrides or fee buckets. Permissioned roles: diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 9db1efea..9751a098 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "64153", + "fork: batchTriggerFeeUpdate 3 pools": "67104", "fork: collect 2 currencies": "58131", "fork: collect single currency": "29307", - "fork: triggerFeeUpdate single pool": "27086" + "fork: triggerFeeUpdate single pool": "29403" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index e319ed04..faafea4d 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,26 +1,26 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "45655", + "adapter.batchTriggerFeeUpdate - two pools": "50289", "adapter.collect - single currency": "31154", "adapter.getFee - pool override hit": "1330", "adapter.setPoolOverride": "26353", - "adapter.triggerFeeUpdate - single pool": "27030", + "adapter.triggerFeeUpdate - single pool": "29347", "policy.batchClearPairClassFee": "6693", - "policy.batchSetHookFamily": "50820", - "policy.batchSetPairClassFee": "53162", - "policy.computeFee - classified family default": "3947", - "policy.computeFee - classified flag-rule self-report": "6956", - "policy.computeFee - classified griefing hook -> defaultFee": "5722", - "policy.computeFee - classified pair class fee": "1742", - "policy.computeFee - classified unclassified -> defaultFee": "5722", - "policy.computeFee - flag-rule multi-flag match": "6956", - "policy.computeFee - flag-rule single flag match": "6956", - "policy.computeFee - static native math buckets": "4832", - "policy.computeFee - static native math pair class fee": "1649", - "policy.setFamilyDefault": "26469", - "policy.setFeeBuckets - 1 bucket": "49769", - "policy.setFeeBuckets - 5 buckets (configuration B)": "125225", - "policy.setFlagRules - 32 rules (max)": "1469615", - "policy.setFlagRules - two rules": "116255", + "policy.batchSetHookFamily": "50887", + "policy.batchSetPairClassFee": "53140", + "policy.computeFee - classified family default": "3928", + "policy.computeFee - classified flag-rule self-report": "6986", + "policy.computeFee - classified griefing hook -> defaultFee": "5723", + "policy.computeFee - classified pair class fee": "1694", + "policy.computeFee - classified unclassified -> defaultFee": "5723", + "policy.computeFee - flag-rule multi-flag match": "6986", + "policy.computeFee - flag-rule single flag match": "6986", + "policy.computeFee - static native math buckets": "7149", + "policy.computeFee - static native math pair class fee": "3938", + "policy.setFamilyDefault": "26447", + "policy.setFeeBuckets - 1 bucket": "49747", + "policy.setFeeBuckets - 5 buckets (configuration B)": "125203", + "policy.setFlagRules - 32 rules (max)": "1469593", + "policy.setFlagRules - two rules": "116233", "policy.setHookFamily": "26257", "policy.setPairClassFee": "27349" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 6fff8c3c..8882d17b 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -21,21 +21,23 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @title V4FeePolicy /// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification /// and a piecewise-linear schedule of fee buckets. -/// @dev Pools are classified into two paths: -/// - StaticNativeMath: no RETURNS_DELTA flags and static fee → -/// `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]`, else evaluate the fee-bucket schedule. -/// - Classified: custom accounting or dynamic fee → resolve family, then -/// `pairClassFees[pair][family]` → `familyDefaults[family]` → `defaultFee`. -/// Hook classification is automated from address bits 0-3 (RETURNS_DELTA flags). -/// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(). -/// Governance-configured flag rules map flag patterns to families automatically. -/// Priority: governance override → flag-rule match on self-reported flags → defaultFee. +/// @dev Fee resolution: resolve a family ID, then apply family-specific logic. +/// - `NATIVE_MATH_FAMILY_ID` (255) on static pools (no RETURNS_DELTA, static LP fee): +/// `pairClassFees[pair][255]` or the fee-bucket schedule. +/// - Governance families (1-255): `pairClassFees[pair][family]` → `familyDefaults[family]` +/// → `defaultFee`. +/// - Unclassified custom-accounting / dynamic-fee pools (`family == 0`): `defaultFee` only. +/// Family resolution: governance `hookFamilyId` → static pools default to native math → +/// hook `protocolFeeFlags()` matched against flag rules → unclassified (0). /// @custom:security-contact security@uniswap.org contract V4FeePolicy is IV4FeePolicy, Owned { using LPFeeLibrary for uint24; /// @inheritdoc IV4FeePolicy - uint8 public constant NATIVE_MATH_FAMILY_ID = 0; + uint8 public constant NATIVE_MATH_FAMILY_ID = 0xFF; + + /// @inheritdoc IV4FeePolicy + uint8 public constant UNCLASSIFIED_FAMILY_ID = 0; /// @dev Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). uint160 public constant CUSTOM_ACCOUNTING_MASK = 0xF; @@ -79,9 +81,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy mapping(bytes32 pairHash => mapping(uint8 familyId => uint24)) public pairClassFees; - /// @dev Ordered fee buckets for the StaticNativeMath path. Ascending by - /// `lpFeeFloor`. Set atomically via `setFeeBuckets`. Empty array → Path A returns 0 - /// when no native pair class fee override exists. + /// @dev Ordered fee buckets for native-math pools. Ascending by `lpFeeFloor`. Set + /// atomically via `setFeeBuckets`. Empty array → native math returns 0 when no pair + /// class fee override exists. FeeBucket[] internal _feeBuckets; /// @dev Maximum number of flag rules to bound gas in _resolveFamily. @@ -114,26 +116,24 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function computeFee(PoolKey calldata key) external view returns (uint24) { - address hook = address(key.hooks); bytes32 ph = _pairHash(key.currency0, key.currency1); + uint8 family = _resolveFamily(key); - // StaticNativeMath: no custom accounting + static fee - if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) { - uint24 stored = pairClassFees[ph][NATIVE_MATH_FAMILY_ID]; - if (stored != 0) return _decodeFee(stored); - return _computeStaticNativeMathFee(key.fee); - } + // Unclassified: use defaultFee. + if (family == UNCLASSIFIED_FAMILY_ID) return _decodeFee(defaultFee); - // Classified: custom accounting OR dynamic fee - uint8 family = _resolveFamily(hook); - if (family == 0) return _decodeFee(defaultFee); + // Explicitly set family: check for pairClassFees first. + uint24 stored = pairClassFees[ph][family]; + if (stored != 0) return _decodeFee(stored); - uint24 pairStored = pairClassFees[ph][family]; - if (pairStored != 0) return _decodeFee(pairStored); + // Native math: use fee buckets. + if (family == NATIVE_MATH_FAMILY_ID) return _computeStaticNativeMathFee(key.fee); + // Fall through to familyDefaults. uint24 famDefault = familyDefaults[family]; if (famDefault != 0) return _decodeFee(famDefault); + // Fall through to defaultFee. return _decodeFee(defaultFee); } @@ -273,12 +273,10 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } /// @inheritdoc IV4FeePolicy - function setPairClassFee( - Currency currency0, - Currency currency1, - uint8 familyId, - uint24 feeValue - ) external onlyFeeSetter { + function setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + external + onlyFeeSetter + { _setPairClassFee(currency0, currency1, familyId, feeValue); } @@ -318,12 +316,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { emit HookFamilySet(hook, familyId); } - function _setPairClassFee( - Currency currency0, - Currency currency1, - uint8 familyId, - uint24 feeValue - ) internal { + function _setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + internal + { if (currency0 >= currency1) revert CurrenciesOutOfOrder(); if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); @@ -344,11 +339,15 @@ contract V4FeePolicy is IV4FeePolicy, Owned { return uint160(hook) & CUSTOM_ACCOUNTING_MASK != 0; } - /// @dev Resolves the family ID for a hook: governance override, then flag rules. - function _resolveFamily(address hook) internal view returns (uint8) { + /// @dev Resolves the family ID for a pool: governance override, native math default for + /// static pools, then flag rules, then unclassified (0). + function _resolveFamily(PoolKey calldata key) internal view returns (uint8) { + address hook = address(key.hooks); uint8 gov = hookFamilyId[hook]; if (gov != 0) return gov; + if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) return NATIVE_MATH_FAMILY_ID; + uint256 rulesLen = _flagRules.length; if (rulesLen == 0) return 0; diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 83021531..cd747d41 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -56,9 +56,9 @@ struct PairClassFeeClear { /// @title IV4FeePolicy /// @notice Interface for the V4 fee policy contract that computes protocol fees based on /// automated hook classification and governance-configured parameters. -/// @dev Hook family IDs are governance-assigned uint8 values (1-255). 0 = unclassified hook -/// on the classified path. `NATIVE_MATH_FAMILY_ID` (0) in `pairClassFees` is a reserved -/// slot for StaticNativeMath pair overrides only — not a classified hook family. +/// @dev Governance hook family IDs are 1-255. 0 = unclassified. `NATIVE_MATH_FAMILY_ID` +/// (255) is also used internally for native-math resolution on static pools; governance may +/// reuse 255 for classified config when appropriate. /// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); /// governance-configured flag rules map flag patterns to families automatically. /// Static NativeMath pools bypass hook classification and derive their protocol fee from @@ -84,7 +84,7 @@ interface IV4FeePolicy { /// @notice Thrown when currency0 >= currency1 in setPairClassFee. error CurrenciesOutOfOrder(); - /// @notice Thrown when a flag rule has requiredFlags == 0 or familyId == 0. + /// @notice Thrown when a flag rule has requiredFlags == 0 or a non-governance familyId. error InvalidFlagRule(); /// @notice Thrown when flag rules exceed the maximum allowed count. @@ -124,7 +124,7 @@ interface IV4FeePolicy { /// @notice Emitted when a pair class fee is updated. /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, /// ZERO_FEE_SENTINEL = explicit zero fee. `familyId` is `NATIVE_MATH_FAMILY_ID` for - /// StaticNativeMath pair overrides, or a classified family ID (1-255) otherwise. + /// native-math pair overrides, or a governance family ID (1-255) otherwise. /// @param pairHash The canonical hash of the token pair. /// @param familyId The family slot that was changed. /// @param feeValue The new encoded fee. @@ -152,11 +152,16 @@ interface IV4FeePolicy { /// @return The bitmask value (0xF). function CUSTOM_ACCOUNTING_MASK() external pure returns (uint160); - /// @notice Reserved `pairClassFees` family slot for StaticNativeMath pair overrides. - /// @dev Not a classified hook family. Distinct from `hookFamilyId == 0` (unclassified). - /// @return 0 + /// @notice Reserved `pairClassFees` family slot for native-math pair overrides. + /// @dev Not assignable as a governance hook family. Distinct from `hookFamilyId == 0`. + /// @return 0xFF (255 == `type(uint8).max`). function NATIVE_MATH_FAMILY_ID() external pure returns (uint8); + /// @notice Reserved `pairClassFees` family slot for unclassified hooks. + /// @dev Not assignable as a governance hook family. Distinct from `NATIVE_MATH_FAMILY_ID`. + /// @return 0 (unclassified). + function UNCLASSIFIED_FAMILY_ID() external pure returns (uint8); + // --- Immutables --- /// @notice The Uniswap V4 PoolManager this policy reads state from. @@ -187,7 +192,8 @@ interface IV4FeePolicy { /// @notice Returns the pair class fee for a token pair and family slot. /// @dev `familyId == NATIVE_MATH_FAMILY_ID` is the StaticNativeMath pair override. - /// Classified pools use family IDs 1-255. 0 in storage = not set. + /// Governance families use IDs 1-255; `NATIVE_MATH_FAMILY_ID` is the native-math slot. + /// 0 in storage = not set. /// @param pairHash The canonical keccak256 hash of the sorted token pair. /// @param familyId The family slot to query. /// @return The sentinel-encoded fee (0 = not set). @@ -228,9 +234,9 @@ interface IV4FeePolicy { // --- Fee Computation --- /// @notice Computes the protocol fee for a pool. - /// @dev StaticNativeMath: `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]` or fee buckets. - /// Classified: resolve family → `pairClassFees[pair][family]` → `familyDefaults[family]` - /// → `defaultFee`. Unclassified (family 0) → `defaultFee` only. + /// @dev Resolve family, then: native math (`NATIVE_MATH_FAMILY_ID` on static pools) → + /// `pairClassFees[pair][family]` → `familyDefaults[family]` → `defaultFee`. + /// Unclassified custom-accounting / dynamic-fee pools (`family == 0`) → `defaultFee` only. /// Callable by anyone (no access control) for offchain tooling. /// @param key The pool key to compute the fee for. /// @return fee The computed protocol fee (two 12-bit directional components packed). @@ -295,8 +301,8 @@ interface IV4FeePolicy { // --- Family Defaults (onlyFeeSetter) --- /// @notice Sets the default protocol fee for a given family ID. - /// @dev familyId must be > 0. Setting 0 sets explicit zero. Use clearFamilyDefault to - /// remove entirely. + /// @dev familyId must be > 0. Setting fee 0 sets explicit zero. + /// Use clearFamilyDefault to remove entirely. /// @param familyId The family to configure. /// @param feeValue The default fee. Must pass isValidProtocolFee if non-zero. function setFamilyDefault(uint8 familyId, uint24 feeValue) external; @@ -308,19 +314,15 @@ interface IV4FeePolicy { // --- Pair Class Fees (onlyFeeSetter) --- /// @notice Sets the pair class fee for a token pair and family slot. - /// @dev Use `NATIVE_MATH_FAMILY_ID` for StaticNativeMath pair overrides (Path A). - /// Use family IDs 1-255 for classified pools (Path B). Setting 0 sets explicit zero - /// and does not fall through. Use clearPairClassFee to remove entirely. + /// @dev Use `NATIVE_MATH_FAMILY_ID` for native-math pair overrides, or 1-255 for + /// governance families. Setting fee 0 sets explicit zero and does not fall through. + /// Use clearPairClassFee to remove entirely. /// @param currency0 The lower currency of the pair (must be < currency1). /// @param currency1 The higher currency of the pair. /// @param familyId The family slot (`NATIVE_MATH_FAMILY_ID` or 1-255). /// @param feeValue The protocol fee. Must pass isValidProtocolFee if non-zero. - function setPairClassFee( - Currency currency0, - Currency currency1, - uint8 familyId, - uint24 feeValue - ) external; + function setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + external; /// @notice Sets multiple pair class fees in one transaction. /// @param assignments Pair/family/fee tuples. Emits `PairClassFeeUpdated` per entry. diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol index 130141ca..c4b3fcdc 100644 --- a/test/V4FeeAdapter.fork.t.sol +++ b/test/V4FeeAdapter.fork.t.sol @@ -11,6 +11,7 @@ import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; import {IHooks} from "v4-core/interfaces/IHooks.sol"; import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; @@ -96,6 +97,69 @@ contract V4FeeAdapterForkTest is Deployers { bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: betaPips}); } + // ============ Unified resolution regression (deployed policy) ============ + + function test_policy_familyIdConstants() public view { + assertEq(policy.NATIVE_MATH_FAMILY_ID(), 0xFF); + assertEq(policy.UNCLASSIFIED_FAMILY_ID(), 0); + } + + function test_policy_dynamicFee_governancePairClassFee() public { + PoolKey memory dynamicPool = PoolKey({ + currency0: currency0, + currency1: currency1, + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setPairClassFee(currency0, currency1, 2, PROTO_FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicPool), PROTO_FEE_100); + } + + function test_policy_staticPool_governanceOptIn_classifiedFamilyDefault() public { + PoolKey memory staticHookPool = PoolKey({ + currency0: currency0, + currency1: currency1, + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(uint160(1 << 7))) + }); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + policy.setHookFamily(address(staticHookPool.hooks), 1); + policy.setFamilyDefault(1, PROTO_FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(staticHookPool), PROTO_FEE_200); + } + + function test_fork_governanceStaticHook_optIn_triggersClassifiedFeeOnManager() public { + IHooks hook = IHooks(address(uint160(1 << 7))); + PoolKey memory staticHookPool; + (staticHookPool,) = initPool(currency0, currency1, hook, 3000, 60, SQRT_PRICE_1_1); + modifyLiquidityRouter.modifyLiquidity( + staticHookPool, + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: 10e18, salt: 0}), + ZERO_BYTES + ); + + vm.startPrank(feeSetter); + policy.setHookFamily(address(staticHookPool.hooks), 1); + policy.setFamilyDefault(1, PROTO_FEE_200); + vm.stopPrank(); + + adapter.triggerFeeUpdate(staticHookPool); + + (,, uint24 fee,) = manager.getSlot0(staticHookPool.toId()); + assertEq(fee, PROTO_FEE_200); + } + // ============ End-to-End: Set Fee -> Swap -> Accrue -> Collect ============ function test_e2e_setFee_swap_collect() public { diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index c40c879c..59a43947 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -644,6 +644,255 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(hookKey), FEE_300); } + function test_computeFee_staticNativeMath_governanceFamilyUsesClassifiedWaterfall() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_200); + } + + function test_familyIdConstants_nativeMath255UnclassifiedZero() public view { + assertEq(policy.NATIVE_MATH_FAMILY_ID(), 0xFF); + assertEq(policy.UNCLASSIFIED_FAMILY_ID(), 0); + } + + function test_computeFee_unclassifiedCustomAccounting_ignoresNativeMathBuckets() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // family 0 (unclassified) must not read fee buckets + assertEq(policy.computeFee(customKey), FEE_100); + } + + function test_computeFee_staticHook_governanceFamily255_skipsFamilyDefaultUsesBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setFamilyDefault(nativeFamily, FEE_500); + vm.stopPrank(); + + // family 255 uses native-math branch, not familyDefaults[255] + assertEq(policy.computeFee(hookKey), FEE_300); + } + + function test_computeFee_staticHook_governanceFamily255_pairClassFeeOverridesBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setPairClassFee( + hookKey.currency0, hookKey.currency1, nativeFamily, FEE_200 + ); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_200); + } + + function test_computeFee_staticHook_governanceFamily255_explicitZeroPairClassFee() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setPairClassFee(hookKey.currency0, hookKey.currency1, nativeFamily, 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), 0); + } + + function test_computeFee_staticHook_governanceFamilyZeroExemptViaFamilyDefault() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), 0); + } + + function test_setHookFamily_and_setFamilyDefault_acceptsFamily255() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setFamilyDefault(nativeFamily, FEE_100); + vm.stopPrank(); + + assertEq(policy.hookFamilyId(address(hookKey.hooks)), nativeFamily); + assertEq(policy.familyDefaults(nativeFamily), FEE_100); + } + + // ============ Unified resolution regression ============ + + function test_computeFee_dynamicFee_unclassified_ignoresNativeMathBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_governancePairClassFeeBeatsFamilyDefault() public { + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 2, FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_governanceFamilyDefaultWhenNoPairFee() public { + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_300); + } + + function test_computeFee_clearHookFamily_restoresNativeMathBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, FEE_200); + policy.setHookFamily(address(hookKey.hooks), 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_300); + } + + function test_computeFee_classified_noFamilyConfigFallsToDefaultFee() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(customHook, 4); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_100); + } + + function test_computeFee_flagRule_family255_usesNativeMathBranchNotFamilyDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(HookFeeFlags.STABLE_PAIR); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: nativeFamily}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setFamilyDefault(nativeFamily, FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_300); + } + + function test_computeFee_flagRule_family255_pairClassFeeOverridesBuckets() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(HookFeeFlags.STABLE_PAIR); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: nativeFamily}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setPairClassFee(key.currency0, key.currency1, nativeFamily, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_200); + } + + function test_computeFee_governanceFamily255_onCustomAccounting_usesNativeMathBranch() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(customHook, nativeFamily); + policy.setFamilyDefault(nativeFamily, FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_300); + } + + function test_computeFee_standardKey_autoNativeFamily_usesBucketsWithoutGovernance() public view { + // address(0) hook + static fee resolves to NATIVE_MATH_FAMILY_ID internally + assertEq(policy.computeFee(standardKey), 0); + } + + function test_computeFee_unclassifiedDynamicFee_notConfusedWithNativeFamily255() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setFamilyDefault(policy.NATIVE_MATH_FAMILY_ID(), FEE_500); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + // ============ Policy: computeFee bucket math ============ function test_computeFee_staticNativeMath_singleBucketFlat() public { @@ -1248,15 +1497,13 @@ contract V4FeeAdapterTest is Test { function test_setPairClassFee_revertsCurrenciesOutOfOrder() public { vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); vm.prank(feeSetter); - policy.setPairClassFee(standardKey.currency1, standardKey.currency0, 0, FEE_200); + policy.setPairClassFee(standardKey.currency1, standardKey.currency0, 1, FEE_200); } function test_setPairClassFee_revertsInvalidFee() public { vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); vm.prank(feeSetter); - policy.setPairClassFee( - standardKey.currency0, standardKey.currency1, 0, (1001 << 12) | 500 - ); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, (1001 << 12) | 500); } function test_batchSetPairClassFee_success() public { @@ -1294,10 +1541,12 @@ contract V4FeeAdapterTest is Test { policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, FEE_300); PairClassFeeClear[] memory clears = new PairClassFeeClear[](2); - clears[0] = - PairClassFeeClear({currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: nativeFamily}); - clears[1] = - PairClassFeeClear({currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: 1}); + clears[0] = PairClassFeeClear({ + currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: nativeFamily + }); + clears[1] = PairClassFeeClear({ + currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: 1 + }); policy.batchClearPairClassFee(clears); vm.snapshotGasLastCall("policy.batchClearPairClassFee"); From d84002ef5da3604c45c4da6822b27727f07bd0ac Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 11:39:27 -0400 Subject: [PATCH 19/26] chore: rename oz audit for merge conflict --- audit/{openzeppelin-4.pdf => openzeppelin-5.pdf} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename audit/{openzeppelin-4.pdf => openzeppelin-5.pdf} (100%) diff --git a/audit/openzeppelin-4.pdf b/audit/openzeppelin-5.pdf similarity index 100% rename from audit/openzeppelin-4.pdf rename to audit/openzeppelin-5.pdf From 955576b16d50bda677ce0cfa46edf2d6bb8ba25a Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 11:43:10 -0400 Subject: [PATCH 20/26] chore: fmt --- test/V4FeeAdapter.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 59a43947..dc1b9954 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -698,9 +698,7 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setHookFamily(address(hookKey.hooks), nativeFamily); - policy.setPairClassFee( - hookKey.currency0, hookKey.currency1, nativeFamily, FEE_200 - ); + policy.setPairClassFee(hookKey.currency0, hookKey.currency1, nativeFamily, FEE_200); vm.stopPrank(); assertEq(policy.computeFee(hookKey), FEE_200); From 9596febd1811cee2fa03eb16b92760f195e4999f Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 14:14:02 -0400 Subject: [PATCH 21/26] chore(docs): update readme, snapshots --- README.md | 37 +++++++++------------- snapshots/V4FeeAdapterForkTest.json | 8 ++--- snapshots/V4FeeAdapterTest.json | 48 ++++++++++++++--------------- 3 files changed, 43 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index d73d7cb8..0fc6cf67 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,12 @@ Fee Sources are adapter contracts that channel fees from various protocols into - `V4FeeAdapter` registered with the PoolManager as `protocolFeeController`; permissionless `triggerFeeUpdate` and `collect` calls push resolved fees to the PoolManager and route accrued revenue to the TokenJar - Fee resolution is split between a thin, long-lived adapter and a replaceable `V4FeePolicy`, so governance can iterate on fee strategy without re-handing PoolManager privileges -- Piecewise-linear protocol fee schedule (per-bucket `alpha + beta × delta` over LP fee) for vanilla pools; family-based classification for hook-using and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution)) +- Piecewise-linear protocol fee schedule for vanilla/static pools; family-based fees for classified hooks and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution) and the [governance operator guide](docs/V4FeePolicy-governance-guide.md)) #### V4 Fee Resolution +**Operator guide:** [docs/V4FeePolicy-governance-guide.md](docs/V4FeePolicy-governance-guide.md) — use cases, recipes, and footguns. + The adapter checks for a per-pool override first, then delegates to the policy: ``` @@ -125,33 +127,24 @@ adapter.poolOverrides[poolId] ──► return (sentinel-decoded) │ └──► policy.computeFee(key) │ - ├── Path A: StaticNativeMath ──► pairClassFees[pair][NATIVE_MATH_FAMILY_ID] OR - │ walk feeBuckets backward, find largest - │ lpFeeFloor <= key.fee (snap to bucket 0 - │ if key.fee < floor_0); return - │ alpha + beta × (key.fee - floor) / 1_000_000 + ├── resolve family (_resolveFamily) + │ hookFamilyId[hook] → else static pool → 255 (native math) + │ else protocolFeeFlags + flagRules → else 0 (unclassified) │ - └── Path B: Classified ────────► familyId resolved from - hookFamilyId[hook] OR hook-reported flags - │ - └─► pairClassFees[pair][family] OR - familyDefaults[family] OR - defaultFee + └── apply fee for family + 0 (unclassified) → defaultFee + pairClassFees[pair][family] (if set) + 255 (native math) → fee buckets from key.fee + 1–255 (classified) → familyDefaults[family] → defaultFee ``` -A pool takes **Path A (StaticNativeMath)** when the hook has no `*_RETURNS_DELTA` flags *and* the LP fee is static (`key.fee != 0x800000`), and **Path B (Classified)** otherwise (custom-accounting hook *or* dynamic-fee pool). `key.fee` is unreliable on Path B because dynamic fees can change every swap, and custom-accounting hooks can rewrite swap deltas, so the bucket schedule doesn't apply there. - -Path A's fee buckets are an ascending-by-`lpFeeFloor` array (max 16) of `(lpFeeFloor, alpha, beta)` triples. Each bucket's `alpha` is a flat per-direction base fee (≤ `MAX_PROTOCOL_FEE = 1000`), and `beta` is a slope in pips per pip of `(lpFee - floor)` (≤ 1_000_000_000). Setting `beta = 0` yields a pure step function; `alpha = 0` yields a slope-only multiplier; both nonzero yields a piecewise-linear curve. Continuity at boundaries is governance's responsibility — the contract does not enforce it. The lowest bucket's `alpha` doubles as a minimum-fee floor for very-low-LP-fee pools, since `key.fee < floor_0` snaps to bucket 0 with `delta = 0`. - -Both paths share one denominator (`MULTIPLIER_DENOMINATOR = 1_000_000`, where `1_000_000 = 100%`) for bucket slope math. Bucket `betaPips` on Path A are capped at 1_000_000_000 (above which the per-direction `MAX_PROTOCOL_FEE = 1000` clamp always saturates). `MultiplierTooLarge` reverts bucket setters that exceed that cap. `NATIVE_MATH_FAMILY_ID` (255) in `pairClassFees` is reserved for native-math pair overrides. Governance families are 1-255; `hookFamilyId == 0` is unclassified. +**Native math (family 255):** pools with no `*_RETURNS_DELTA` hook bits and static LP fee (`key.fee != 0x800000`). Fee = `pairClassFees[pair][255]` if set, else piecewise-linear **fee buckets** (`alpha + beta × (lpFee - floor) / 1_000_000`, max 16 buckets). -`familyId` resolution for Path B: +**Classified (families 1–255 via governance):** custom-accounting hooks, dynamic-fee pools, or any hook with `setHookFamily`. Waterfall: `pairClassFees` → `familyDefaults` → `defaultFee`. `key.fee` is not used for bucket math on these pools. -1. `hookFamilyId[hook]` (governance override) — wins if non-zero -2. gas-capped staticcall to `hook.protocolFeeFlags()` (optional `IFeeClassifiedHook` interface) → walk governance-configured `flagRules` first-match-wins -3. otherwise unclassified → falls through to `defaultFee` +**Unclassified (family 0):** `defaultFee` only (no buckets, no family defaults). -With a governance family (1-255), the policy returns `pairClassFees[ph][family]` if set, else `familyDefaults[family]`, else falls through to `defaultFee`. An explicit-zero pair class fee still short-circuits to zero. Unclassified hooks (`family == 0`) use `defaultFee` only. Static pools resolve to `NATIVE_MATH_FAMILY_ID` (255) and use pair overrides or fee buckets. +Constants: `NATIVE_MATH_FAMILY_ID = 255`, `UNCLASSIFIED_FAMILY_ID = 0`. After policy or override changes, call `triggerFeeUpdate` so the PoolManager picks up new fees. Permissioned roles: diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 9751a098..0a694516 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "67104", - "fork: collect 2 currencies": "58131", - "fork: collect single currency": "29307", - "fork: triggerFeeUpdate single pool": "29403" + "fork: batchTriggerFeeUpdate 3 pools": "98416", + "fork: collect 2 currencies": "99567", + "fork: collect single currency": "62947", + "fork: triggerFeeUpdate single pool": "58123" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index faafea4d..5cb5364d 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,26 +1,26 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "50289", - "adapter.collect - single currency": "31154", - "adapter.getFee - pool override hit": "1330", - "adapter.setPoolOverride": "26353", - "adapter.triggerFeeUpdate - single pool": "29347", - "policy.batchClearPairClassFee": "6693", - "policy.batchSetHookFamily": "50887", - "policy.batchSetPairClassFee": "53140", - "policy.computeFee - classified family default": "3928", - "policy.computeFee - classified flag-rule self-report": "6986", - "policy.computeFee - classified griefing hook -> defaultFee": "5723", - "policy.computeFee - classified pair class fee": "1694", - "policy.computeFee - classified unclassified -> defaultFee": "5723", - "policy.computeFee - flag-rule multi-flag match": "6986", - "policy.computeFee - flag-rule single flag match": "6986", - "policy.computeFee - static native math buckets": "7149", - "policy.computeFee - static native math pair class fee": "3938", - "policy.setFamilyDefault": "26447", - "policy.setFeeBuckets - 1 bucket": "49747", - "policy.setFeeBuckets - 5 buckets (configuration B)": "125203", - "policy.setFlagRules - 32 rules (max)": "1469593", - "policy.setFlagRules - two rules": "116233", - "policy.setHookFamily": "26257", - "policy.setPairClassFee": "27349" + "adapter.batchTriggerFeeUpdate - two pools": "80457", + "adapter.collect - single currency": "58078", + "adapter.getFee - pool override hit": "3330", + "adapter.setPoolOverride": "48093", + "adapter.triggerFeeUpdate - single pool": "58067", + "policy.batchClearPairClassFee": "33112", + "policy.batchSetHookFamily": "72791", + "policy.batchSetPairClassFee": "76564", + "policy.computeFee - classified family default": "7928", + "policy.computeFee - classified flag-rule self-report": "17486", + "policy.computeFee - classified griefing hook -> defaultFee": "7723", + "policy.computeFee - classified pair class fee": "5694", + "policy.computeFee - classified unclassified -> defaultFee": "7723", + "policy.computeFee - flag-rule multi-flag match": "17486", + "policy.computeFee - flag-rule single flag match": "17486", + "policy.computeFee - static native math buckets": "11149", + "policy.computeFee - static native math pair class fee": "5938", + "policy.setFamilyDefault": "47815", + "policy.setFeeBuckets - 1 bucket": "71511", + "policy.setFeeBuckets - 5 buckets (configuration B)": "148719", + "policy.setFlagRules - 32 rules (max)": "1499897", + "policy.setFlagRules - two rules": "138137", + "policy.setHookFamily": "47601", + "policy.setPairClassFee": "49453" } \ No newline at end of file From 52b3a680b0553bf284aab154e39205ad88ceb737 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 14:16:18 -0400 Subject: [PATCH 22/26] chore(docs): add v4 policy governance guide --- docs/V4FeePolicy-governance-guide.md | 288 +++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 docs/V4FeePolicy-governance-guide.md diff --git a/docs/V4FeePolicy-governance-guide.md b/docs/V4FeePolicy-governance-guide.md new file mode 100644 index 00000000..0005cf05 --- /dev/null +++ b/docs/V4FeePolicy-governance-guide.md @@ -0,0 +1,288 @@ +# V4 Fee Policy — Governance Operator Guide + +This guide helps **fee setters** choose the right knob for a fee outcome on Uniswap V4. It reflects the unified `V4FeePolicy` resolution model (`NATIVE_MATH_FAMILY_ID = 255`, `UNCLASSIFIED_FAMILY_ID = 0`). + +**Contracts** + + +| Contract | Role | +| -------------- | ---------------------------------------------------------------------------------------------- | +| `V4FeeAdapter` | `protocolFeeController` on the PoolManager; per-pool overrides; `triggerFeeUpdate` / `collect` | +| `V4FeePolicy` | Replaceable fee logic; families, buckets, pair/family defaults | + + +**Roles** + + +| Role | Can do | +| ------------------------------ | -------------------------------------------------- | +| `feeSetter` (adapter + policy) | All fee configuration below | +| `owner` (adapter + policy) | Set `feeSetter`, swap `V4FeePolicy` on the adapter | + + +--- + +## End-to-end fee waterfall + +Every pool’s protocol fee is resolved in this order: + +``` +1. adapter.poolOverrides[poolId] → if set, use this (always wins) +2. policy.computeFee(poolKey) → see below +3. adapter returns 0 → if policy address is unset +``` + +Inside `**policy.computeFee**`: + +``` +1. family := _resolveFamily(poolKey) + +2. if family == 0 (unclassified) + → defaultFee + +3. if pairClassFees[pair][family] is set + → that fee (including explicit zero) + +4. if family == 255 (native math) + → fee buckets from key.fee (piecewise linear) + +5. if familyDefaults[family] is set + → family default + +6. → defaultFee +``` + +--- + +## How `family` is chosen (`_resolveFamily`) + + +| Step | Source | Result | +| ---- | -------------------------------------------------------------------- | ---------------------------------------------- | +| 1 | `hookFamilyId[hook] != 0` | Use governance assignment (families **1–255**) | +| 2 | Static pool: no return-delta hook bits **and** `key.fee != 0x800000` | **255** (native math) | +| 3 | `hook.protocolFeeFlags()` + `flagRules` (first match) | Rule’s `familyId` | +| 4 | Else | **0** (unclassified) | + + +**Static pool** = hook address has none of v4’s `*_RETURNS_DELTA` permission bits (low bits 0–3) and LP fee is not the dynamic-fee marker `0x800000`. + +**Custom-accounting hook** = any return-delta bit set in the hook address (baked in at deploy). These pools never auto-resolve to native math unless you assign a family in step 1. + +**Dynamic-fee pool** = `key.fee == 0x800000`. Live LP fee can change every swap; buckets are not used. Assign a family or rely on unclassified + `defaultFee`. + +Check onchain: `policy.isCustomAccounting(hook)` and `LPFeeLibrary.isDynamicFee(key.fee)`. + +--- + +## Pool types at a glance + + +| Pool shape | Default `family` | Fee mechanics | +| ------------------------------------------------ | ------------------------------ | --------------------------------------------- | +| Vanilla (`hooks = 0`, static LP fee) | **255** | `pairClassFees[pair][255]` or **fee buckets** | +| Static hook (e.g. `beforeSwap` only, static fee) | **255** unless `setHookFamily` | Same as vanilla | +| Custom-accounting or dynamic-fee, no assignment | **0** | `**defaultFee` only** | +| Any pool with `setHookFamily(hook, F)` | **F** | Classified waterfall (steps 3–6 above) | + + +--- + +## Use case → best mechanism + + +| Goal | Use | Contract | Notes | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Protocol fee scales with LP fee tier** (0.05% / 0.3% / 1% pools) | `setFeeBuckets` | Policy | Default for vanilla + static-hook pools (`family` 255). Max 16 buckets, ascending `lpFeeFloor`. | +| **Different fee for one token pair** (all vanilla pools on that pair) | `setPairClassFee(c0, c1, 255, fee)` | Policy | `255` = `NATIVE_MATH_FAMILY_ID`. Overrides buckets for that pair only. | +| **Force one pool’s fee** (promo, migration, emergency) | `setPoolOverride(poolId, fee)` | Adapter | Highest priority; ignores policy. Use `0` for explicit zero. | +| **Zero fee on one pool** | `setPoolOverride(poolId, 0)` | Adapter | Simplest per-pool exempt. | +| **Flat fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, fee)` | Policy | Works for **static hooks** (opt out of buckets) and classified hooks. Pick unused family **1–254** (or 255; see footguns). | +| **Zero fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, 0)` | Policy | `0` encodes as explicit-zero sentinel. | +| **Zero fee for one pair on a hook family** | `setPairClassFee(c0, c1, F, 0)` | Policy | Does not fall through to family default. | +| **Per-pair fee within a hook family** | `setPairClassFee(c0, c1, F, fee)` | Policy | Finer than family default. | +| **Auto-group hooks by behavior** | `setFlagRules` + `setFamilyDefault` per family | Policy | Hook must implement `IFeeClassifiedHook.protocolFeeFlags()`. Rules sorted most-specific first (higher popcount). Max 32 rules. | +| **Override auto-classification for one hook** | `setHookFamily(hook, F)` | Policy | Beats flag rules. `setHookFamily(hook, 0)` clears assignment. | +| **Default for unclassified hooks / dynamic pools** | `setDefaultFee` | Policy | Used when `family == 0`. | +| **Remove a configured fee** | `clearFeeBuckets`, `clearFamilyDefault`, `clearPairClassFee`, `clearDefaultFee`, `clearPoolOverride` | Policy / Adapter | Prefer `clear`* over writing zero to “unset” bucket/default slots. | +| **Apply new policy logic chain-wide** | Deploy policy → `adapter.setPolicy` → `triggerFeeUpdate` / `batchTriggerFeeUpdate` | Adapter | PoolManager keeps old fee until retriggered. | + + +--- + +## Mechanism reference + +### Adapter (`V4FeeAdapter`) + + +| Function | Purpose | +| ------------------------------ | ------------------------------------------------- | +| `setPoolOverride(poolId, fee)` | Per-pool fee; wins over everything | +| `clearPoolOverride(poolId)` | Remove override | +| `triggerFeeUpdate(key)` | Push resolved fee to PoolManager (permissionless) | +| `batchTriggerFeeUpdate(keys)` | Batch retrigger | +| `collect(...)` | Pull accrued fees to TokenJar (permissionless) | + + +### Policy — native math (family 255) + + +| Function | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `setFeeBuckets(buckets)` | Global schedule: `alpha + beta × (lpFee - floor) / 1_000_000` per direction, clamped to 1000 pips | +| `clearFeeBuckets()` | Remove schedule (native math returns 0 if no pair override) | +| `setPairClassFee(c0, c1, 255, fee)` | Pair-specific override on native path | + + +Bucket tips: + +- `beta = 0` → flat `alpha` per tier. +- `alpha = 0` → pure multiplier on LP fee. +- Lowest bucket’s `alpha` is the floor when `lpFee < floor_0`. + +### Policy — classified families (1–255) + + +| Function | Purpose | +| ---------------------------------------- | --------------------------------------------------------- | +| `setHookFamily(hook, familyId)` | Manual family; `0` = clear | +| `batchSetHookFamily(assignments)` | Batch version | +| `setFamilyDefault(familyId, fee)` | Default for family when no pair override (`familyId > 0`) | +| `setPairClassFee(c0, c1, familyId, fee)` | Pair + family slot | +| `setFlagRules(rules)` | Map `protocolFeeFlags()` → family | +| `clearFlagRules()` | Remove all rules | +| `setDefaultFee(fee)` | Unclassified + final fallback | + + +### Policy — constants (read-only) + + +| Constant | Value | Meaning | +| -------------------------- | ----- | --------------------------------------------- | +| `NATIVE_MATH_FAMILY_ID()` | `255` | Native math + `pairClassFees[pair][255]` slot | +| `UNCLASSIFIED_FAMILY_ID()` | `0` | No family; `defaultFee` only | + + +--- + +## Flag rules and `HookFeeFlags` + +Hooks that implement `protocolFeeFlags()` return a `uint256` of behavioral bits (see `HookFeeFlags.sol`). Governance lists rules: + +```solidity +FlagRule({ requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 3 }) +``` + +- `requiredFlags` must be non-zero; all listed bits must be set on the hook’s report. +- `familyId` must be **> 0** in rules (use family 3, not 0). +- Order rules from **most specific** to **least** (decreasing popcount of `requiredFlags`). + +`HookFeeFlags` does **not** affect `family` by itself — only `flagRules` + the hook’s returned flags do. + +--- + +## Recipes (copy-paste patterns) + +### A. Turn on tiered fees for all vanilla pools + +```text +setFeeBuckets([...]) // policy +batchTriggerFeeUpdate(keys) // adapter — all target pools +``` + +### B. WETH/USDC pair pays 5 bps protocol fee everywhere (vanilla pools) + +```text +setPairClassFee(WETH, USDC, 255, fee) // sorted: currency0 < currency1 +triggerFeeUpdate(...) // per pool or batch +``` + +### C. Partner static-hook: flat 3 bps on all their pools + +```text +setHookFamily(hook, 7) +setFamilyDefault(7, fee_300) +batchTriggerFeeUpdate(partnerPoolKeys) +``` + +### D. Exempt partner static-hook entirely + +```text +setHookFamily(hook, 7) +setFamilyDefault(7, 0) // explicit zero +batchTriggerFeeUpdate(...) +``` + +### E. Custom-accounting hook: classify via flags + +```text +setFlagRules([{ STABLE_PAIR, 4 }, { AGGREGATOR, 5 }, ...]) +setFamilyDefault(4, fee_stable) +setFamilyDefault(5, fee_agg) +// hooks with protocolFeeFlags() pick up families automatically +``` + +### F. One pool only: zero fee (e.g. launch pool) + +```text +setPoolOverride(poolId, 0) // adapter — no policy family needed +triggerFeeUpdate(key) +``` + +### G. Dynamic-fee pool with governance family + +```text +setHookFamily(hook, 2) // or address(0) if no hook +setFamilyDefault(2, fee) +// optional: setPairClassFee for specific pairs +triggerFeeUpdate(key) +``` + +--- + +## Footguns + +1. **Config ≠ live fee** — Changing policy or overrides does not update PoolManager until `triggerFeeUpdate` (anyone can call). +2. `**setHookFamily(hook, 255)` on static pools** — Family 255 runs the **native-math branch** (buckets / `pairClassFees[pair][255]`), not `familyDefaults[255]`. To store a literal default at slot 255, use pair class fees or buckets; `setFamilyDefault(255, …)` does not apply on that code path. +3. **Unclassified ≠ native math** — `family == 0` uses only `defaultFee`. It never reads fee buckets, even if buckets are configured. +4. **Explicit zero vs unset** — `setFamilyDefault(F, 0)` and `setPairClassFee(..., 0)` mean **zero fee**. To remove config, use `clearFamilyDefault` / `clearPairClassFee`. +5. **Pair ordering** — `setPairClassFee` requires `currency0 < currency1` (sorted addresses). +6. **Dynamic LP fee** — Do not rely on `key.fee` for amount on dynamic pools; assign a family and use family/pair defaults. +7. **Return-delta hooks** — Address bits 0–3 force classified path unless you `setHookFamily`. `beforeSwap`-only hooks (bit 7, etc.) stay on native math by default. +8. **Policy swap** — `adapter.setPolicy(newPolicy)` requires retrigger on all pools; consider batch. + +--- + +## Quick decision tree + +```text +Need to set fee for ONE pool only? + YES → adapter.setPoolOverride + NO ↓ + +Pool vanilla (no hook / static hook, static LP fee)? + YES → fee buckets and/or pairClassFees[..., 255] + NO ↓ + +Know the hook address and want same fee on all its pools? + YES → setHookFamily + setFamilyDefault (or pairClassFees) + NO ↓ + +Want hooks to self-select by behavior? + YES → setFlagRules + setFamilyDefaults + NO ↓ + +Fallback for odd hooks / dynamic fee / no rules match? + → setDefaultFee +``` + +--- + +## Related code + +- `src/feeAdapters/V4FeePolicy.sol` — `computeFee`, `_resolveFamily` +- `src/feeAdapters/V4FeeAdapter.sol` — `getFee`, overrides +- `src/libraries/HookFeeFlags.sol` — flag vocabulary +- `src/interfaces/IFeeClassifiedHook.sol` — hook self-report interface +- `test/V4FeeAdapter.t.sol` — unit tests (search `Unified resolution regression`) + From 0617abb41a153f3dfd1e82e36e52d2f3ce6c2afd Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Thu, 28 May 2026 16:24:26 -0400 Subject: [PATCH 23/26] fix(v4): reject setFamilyDefault on native-math family id familyDefaults[255] was unreachable in computeFee; block writes at the setter so governance cannot configure a dead slot. Pair class fees and buckets remain the knobs for family 255. --- docs/V4FeePolicy-governance-guide.md | 6 +++--- snapshots/V4FeeAdapterTest.json | 2 +- src/feeAdapters/V4FeePolicy.sol | 7 ++++--- src/interfaces/IV4FeePolicy.sol | 11 ++++++----- test/V4FeeAdapter.t.sol | 27 +++++++++++++++++---------- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/V4FeePolicy-governance-guide.md b/docs/V4FeePolicy-governance-guide.md index 0005cf05..2a670d8e 100644 --- a/docs/V4FeePolicy-governance-guide.md +++ b/docs/V4FeePolicy-governance-guide.md @@ -97,7 +97,7 @@ Check onchain: `policy.isCustomAccounting(hook)` and `LPFeeLibrary.isDynamicFee( | **Different fee for one token pair** (all vanilla pools on that pair) | `setPairClassFee(c0, c1, 255, fee)` | Policy | `255` = `NATIVE_MATH_FAMILY_ID`. Overrides buckets for that pair only. | | **Force one pool’s fee** (promo, migration, emergency) | `setPoolOverride(poolId, fee)` | Adapter | Highest priority; ignores policy. Use `0` for explicit zero. | | **Zero fee on one pool** | `setPoolOverride(poolId, 0)` | Adapter | Simplest per-pool exempt. | -| **Flat fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, fee)` | Policy | Works for **static hooks** (opt out of buckets) and classified hooks. Pick unused family **1–254** (or 255; see footguns). | +| **Flat fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, fee)` | Policy | Works for **static hooks** (opt out of buckets) and classified hooks. Use governance family **1–254** (`setFamilyDefault` rejects 255). | | **Zero fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, 0)` | Policy | `0` encodes as explicit-zero sentinel. | | **Zero fee for one pair on a hook family** | `setPairClassFee(c0, c1, F, 0)` | Policy | Does not fall through to family default. | | **Per-pair fee within a hook family** | `setPairClassFee(c0, c1, F, fee)` | Policy | Finer than family default. | @@ -147,7 +147,7 @@ Bucket tips: | ---------------------------------------- | --------------------------------------------------------- | | `setHookFamily(hook, familyId)` | Manual family; `0` = clear | | `batchSetHookFamily(assignments)` | Batch version | -| `setFamilyDefault(familyId, fee)` | Default for family when no pair override (`familyId > 0`) | +| `setFamilyDefault(familyId, fee)` | Default for governance families **1–254** (rejects 0 and 255) | | `setPairClassFee(c0, c1, familyId, fee)` | Pair + family slot | | `setFlagRules(rules)` | Map `protocolFeeFlags()` → family | | `clearFlagRules()` | Remove all rules | @@ -243,7 +243,7 @@ triggerFeeUpdate(key) ## Footguns 1. **Config ≠ live fee** — Changing policy or overrides does not update PoolManager until `triggerFeeUpdate` (anyone can call). -2. `**setHookFamily(hook, 255)` on static pools** — Family 255 runs the **native-math branch** (buckets / `pairClassFees[pair][255]`), not `familyDefaults[255]`. To store a literal default at slot 255, use pair class fees or buckets; `setFamilyDefault(255, …)` does not apply on that code path. +2. **Family 255 and `familyDefaults`** — Native math uses buckets and `pairClassFees[pair][255]`. `setFamilyDefault(255, …)` and `clearFamilyDefault(255)` **revert** (`InvalidFamilyId`). `setHookFamily(hook, 255)` is still allowed to force the native-math branch on classified pools. 3. **Unclassified ≠ native math** — `family == 0` uses only `defaultFee`. It never reads fee buckets, even if buckets are configured. 4. **Explicit zero vs unset** — `setFamilyDefault(F, 0)` and `setPairClassFee(..., 0)` mean **zero fee**. To remove config, use `clearFamilyDefault` / `clearPairClassFee`. 5. **Pair ordering** — `setPairClassFee` requires `currency0 < currency1` (sorted addresses). diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 5cb5364d..04b708b8 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -16,7 +16,7 @@ "policy.computeFee - flag-rule single flag match": "17486", "policy.computeFee - static native math buckets": "11149", "policy.computeFee - static native math pair class fee": "5938", - "policy.setFamilyDefault": "47815", + "policy.setFamilyDefault": "47850", "policy.setFeeBuckets - 1 bucket": "71511", "policy.setFeeBuckets - 5 buckets (configuration B)": "148719", "policy.setFlagRules - 32 rules (max)": "1499897", diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 8882d17b..0e5af60e 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -24,8 +24,8 @@ import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; /// @dev Fee resolution: resolve a family ID, then apply family-specific logic. /// - `NATIVE_MATH_FAMILY_ID` (255) on static pools (no RETURNS_DELTA, static LP fee): /// `pairClassFees[pair][255]` or the fee-bucket schedule. -/// - Governance families (1-255): `pairClassFees[pair][family]` → `familyDefaults[family]` -/// → `defaultFee`. +/// - Governance families (1-254): `pairClassFees[pair][family]` → `familyDefaults[family]` +/// → `defaultFee`. Family 255 uses buckets only (`familyDefaults[255]` is rejected). /// - Unclassified custom-accounting / dynamic-fee pools (`family == 0`): `defaultFee` only. /// Family resolution: governance `hookFamilyId` → static pools default to native math → /// hook `protocolFeeFlags()` matched against flag rules → unclassified (0). @@ -259,7 +259,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function setFamilyDefault(uint8 familyId, uint24 feeValue) external onlyFeeSetter { - if (familyId == 0) revert InvalidFamilyId(); + if (familyId == 0 || familyId == NATIVE_MATH_FAMILY_ID) revert InvalidFamilyId(); if (feeValue != 0) _validateFee(feeValue); uint24 stored = _encodeFee(feeValue); familyDefaults[familyId] = stored; @@ -268,6 +268,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { /// @inheritdoc IV4FeePolicy function clearFamilyDefault(uint8 familyId) external onlyFeeSetter { + if (familyId == 0 || familyId == NATIVE_MATH_FAMILY_ID) revert InvalidFamilyId(); delete familyDefaults[familyId]; emit FamilyDefaultUpdated(familyId, 0); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index cd747d41..65a86b95 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -75,7 +75,7 @@ interface IV4FeePolicy { /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. error InvalidFeeValue(); - /// @notice Thrown when familyId == 0 is passed to a function that requires > 0. + /// @notice Thrown when familyId is 0 or `NATIVE_MATH_FAMILY_ID` on family-default setters. error InvalidFamilyId(); /// @notice Thrown when a fee bucket's `betaPips` exceeds 1_000_000_000. @@ -300,14 +300,15 @@ interface IV4FeePolicy { // --- Family Defaults (onlyFeeSetter) --- - /// @notice Sets the default protocol fee for a given family ID. - /// @dev familyId must be > 0. Setting fee 0 sets explicit zero. - /// Use clearFamilyDefault to remove entirely. + /// @notice Sets the default protocol fee for a governance family (1-254). + /// @dev Reverts for 0 and `NATIVE_MATH_FAMILY_ID` (use pair class fees / buckets for 255). + /// Setting fee 0 sets explicit zero. Use clearFamilyDefault to remove entirely. /// @param familyId The family to configure. /// @param feeValue The default fee. Must pass isValidProtocolFee if non-zero. function setFamilyDefault(uint8 familyId, uint24 feeValue) external; - /// @notice Removes the default fee for a family, falling through in the waterfall. + /// @notice Removes the default fee for a governance family (1-254). + /// @dev Reverts for 0 and `NATIVE_MATH_FAMILY_ID`. /// @param familyId The family to clear. function clearFamilyDefault(uint8 familyId) external; diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index dc1b9954..20da0d63 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -685,10 +685,9 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setHookFamily(address(hookKey.hooks), nativeFamily); - policy.setFamilyDefault(nativeFamily, FEE_500); vm.stopPrank(); - // family 255 uses native-math branch, not familyDefaults[255] + // family 255 uses native-math branch (buckets), not familyDefaults[255] assertEq(policy.computeFee(hookKey), FEE_300); } @@ -726,16 +725,13 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(hookKey), 0); } - function test_setHookFamily_and_setFamilyDefault_acceptsFamily255() public { + function test_setHookFamily_acceptsFamily255() public { uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); - vm.startPrank(feeSetter); + vm.prank(feeSetter); policy.setHookFamily(address(hookKey.hooks), nativeFamily); - policy.setFamilyDefault(nativeFamily, FEE_100); - vm.stopPrank(); assertEq(policy.hookFamilyId(address(hookKey.hooks)), nativeFamily); - assertEq(policy.familyDefaults(nativeFamily), FEE_100); } // ============ Unified resolution regression ============ @@ -820,7 +816,6 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFlagRules(rules); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); - policy.setFamilyDefault(nativeFamily, FEE_500); vm.stopPrank(); assertEq(policy.computeFee(key), FEE_300); @@ -870,7 +865,6 @@ contract V4FeeAdapterTest is Test { vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); policy.setHookFamily(customHook, nativeFamily); - policy.setFamilyDefault(nativeFamily, FEE_500); vm.stopPrank(); assertEq(policy.computeFee(customKey), FEE_300); @@ -884,7 +878,6 @@ contract V4FeeAdapterTest is Test { function test_computeFee_unclassifiedDynamicFee_notConfusedWithNativeFamily255() public { vm.startPrank(feeSetter); policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); - policy.setFamilyDefault(policy.NATIVE_MATH_FAMILY_ID(), FEE_500); policy.setDefaultFee(FEE_100); vm.stopPrank(); @@ -1481,6 +1474,20 @@ contract V4FeeAdapterTest is Test { policy.setFamilyDefault(0, FEE_100); } + function test_setFamilyDefault_revertsNativeMathFamily() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.setFamilyDefault(nativeFamily, FEE_100); + } + + function test_clearFamilyDefault_revertsNativeMathFamily() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.clearFamilyDefault(nativeFamily); + } + function test_setPairClassFee_success() public { bytes32 ph = _pairHash(); uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); From ae55fd9e71f73d4b78e1f2e8a2c65326f86f935d Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 5 Jun 2026 17:53:16 -0400 Subject: [PATCH 24/26] refactor(v4): remove HookFeeFlags library in favor of documented conventions The HookFeeFlags library shipped a speculative, non-exhaustive vocabulary of behavioral bits onchain, but the policy ascribes no meaning to any bit: _resolveFamily only checks that a rule's requiredFlags are a subset of the hook's opaque uint256 self-report. Enumerating bits in a library implied a contract-enforced semantics that does not exist (cf. the misleadingly-named USES_DYNAMIC_FEE, where per-pool dynamic status is read authoritatively from key.fee, not the hook's pool-agnostic report). Move the flag vocabulary to the governance guide, where governance and hook authors share one source of truth, and document the only convention in active use (aggregator = bit 11). Test scaffolding keeps a local HookFeeFlags helper since the matching tests need distinct named bits. - Delete src/libraries/HookFeeFlags.sol - Add test/utils/HookFeeFlags.sol (test-only fixture, same name) - Update IFeeClassifiedHook / IV4FeePolicy comments to call the bitfield opaque - Replace the governance guide's HookFeeFlags section with a conventions table --- docs/V4FeePolicy-governance-guide.md | 26 ++++++++++++++++++------ src/interfaces/IFeeClassifiedHook.sol | 11 +++++----- src/interfaces/IV4FeePolicy.sol | 3 ++- src/libraries/HookFeeFlags.sol | 29 --------------------------- test/V4FeeAdapter.t.sol | 2 +- test/utils/HookFeeFlags.sol | 17 ++++++++++++++++ 6 files changed, 46 insertions(+), 42 deletions(-) delete mode 100644 src/libraries/HookFeeFlags.sol create mode 100644 test/utils/HookFeeFlags.sol diff --git a/docs/V4FeePolicy-governance-guide.md b/docs/V4FeePolicy-governance-guide.md index 2a670d8e..8afeb251 100644 --- a/docs/V4FeePolicy-governance-guide.md +++ b/docs/V4FeePolicy-governance-guide.md @@ -165,19 +165,34 @@ Bucket tips: --- -## Flag rules and `HookFeeFlags` +## Flag rules -Hooks that implement `protocolFeeFlags()` return a `uint256` of behavioral bits (see `HookFeeFlags.sol`). Governance lists rules: +Hooks that implement `protocolFeeFlags()` return an opaque `uint256` bitfield. The policy +ascribes **no** meaning to any individual bit — per rule, it only checks that all of a +rule's `requiredFlags` bits are set in the hook's returned value. The meaning of each bit +is a convention agreed between governance (which writes the rules) and hooks (which set the +bits); it is **not** enumerated onchain. ```solidity -FlagRule({ requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 3 }) +// bit 11 (1 << 11) is the aggregator convention — see the table below +FlagRule({ requiredFlags: 1 << 11, familyId: 3 }) ``` -- `requiredFlags` must be non-zero; all listed bits must be set on the hook’s report. +- `requiredFlags` must be non-zero; all listed bits must be set on the hook's report. - `familyId` must be **> 0** in rules (use family 3, not 0). - Order rules from **most specific** to **least** (decreasing popcount of `requiredFlags`). -`HookFeeFlags` does **not** affect `family` by itself — only `flagRules` + the hook’s returned flags do. +Flag rules do **not** affect `family` by themselves — only the combination of `flagRules` +and the hook's returned bits does. + +### Bit conventions in active use + +| Bit | Value | Convention | Notes | +| --------- | ------ | --------------- | -------------------------------------------------- | +| `1 << 11` | `2048` | Aggregator hook | Only convention currently relied on in production. | + +Bits not listed here have no agreed meaning. Before publishing a rule against a new bit, +add it to this table so hook authors and governance share one source of truth. --- @@ -282,7 +297,6 @@ Fallback for odd hooks / dynamic fee / no rules match? - `src/feeAdapters/V4FeePolicy.sol` — `computeFee`, `_resolveFamily` - `src/feeAdapters/V4FeeAdapter.sol` — `getFee`, overrides -- `src/libraries/HookFeeFlags.sol` — flag vocabulary - `src/interfaces/IFeeClassifiedHook.sol` — hook self-report interface - `test/V4FeeAdapter.t.sol` — unit tests (search `Unified resolution regression`) diff --git a/src/interfaces/IFeeClassifiedHook.sol b/src/interfaces/IFeeClassifiedHook.sol index 58501b42..58cffd00 100644 --- a/src/interfaces/IFeeClassifiedHook.sol +++ b/src/interfaces/IFeeClassifiedHook.sol @@ -4,14 +4,15 @@ pragma solidity ^0.8.26; /// @title IFeeClassifiedHook /// @notice Optional interface for v4 hooks to self-report behavioral flags for /// protocol fee classification. -/// @dev Hooks return a uint256 bitfield of OR'd flags from HookFeeFlags. The -/// V4FeePolicy matches these flags against governance-configured rules to derive -/// a family ID. Gas-capped staticcall prevents griefing. -/// Governance can always override via setHookFamily(). +/// @dev Hooks return an opaque uint256 bitfield. The V4FeePolicy ascribes no meaning +/// to individual bits — it only matches the returned value against governance-configured +/// flag rules to derive a family ID. Gas-capped staticcall prevents griefing. +/// Governance can always override via setHookFamily(). See the governance guide for the +/// bit conventions in active use. /// @custom:security-contact security@uniswap.org interface IFeeClassifiedHook { /// @notice Returns the hook's self-reported behavioral flags. /// @dev Return 0 to indicate no self-classification (falls through to defaultFee). - /// Flags are OR'd constants from HookFeeFlags — see that library for the vocabulary. + /// The bitfield is opaque to the policy; see the governance guide for active conventions. function protocolFeeFlags() external view returns (uint256); } diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol index 65a86b95..c23b6f3a 100644 --- a/src/interfaces/IV4FeePolicy.sol +++ b/src/interfaces/IV4FeePolicy.sol @@ -26,7 +26,8 @@ struct FeeBucket { /// whose requiredFlags are all present in the hook's self-reported flags wins. struct FlagRule { /// @dev Bitmask of flags that must ALL be set in the hook's protocolFeeFlags() return - /// value for this rule to match. Use OR'd constants from HookFeeFlags. + /// value for this rule to match. The bits are an opaque convention between governance + /// and hooks; see the governance guide for the values in active use. uint256 requiredFlags; /// @dev The family ID assigned when this rule matches. Must be > 0. uint8 familyId; diff --git a/src/libraries/HookFeeFlags.sol b/src/libraries/HookFeeFlags.sol deleted file mode 100644 index bdd6ffd2..00000000 --- a/src/libraries/HookFeeFlags.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -pragma solidity ^0.8.26; - -/// @title HookFeeFlags -/// @notice Well-known behavioral flags for hook fee classification. -/// @dev Hooks OR these flags together and return the result from protocolFeeFlags(). -/// The V4FeePolicy matches returned flags against governance-configured rules to -/// derive a family ID. Flags occupy a uint256; bits 0-11 are defined here with -/// room for future additions. -/// @custom:security-contact security@uniswap.org -library HookFeeFlags { - // --- Value extraction (bits 0-3) --- - uint256 internal constant TAKES_SWAP_SURPLUS = 1 << 0; - uint256 internal constant TAKES_LP_SURPLUS = 1 << 1; - uint256 internal constant USES_DYNAMIC_FEE = 1 << 2; - uint256 internal constant REBALANCES_POOL = 1 << 3; - - // --- Strategy type (bits 4-7) --- - uint256 internal constant STABLE_PAIR = 1 << 4; - uint256 internal constant ORACLE_BASED = 1 << 5; - uint256 internal constant LIMIT_ORDER = 1 << 6; - uint256 internal constant AUCTION_BASED = 1 << 7; - - // --- Integration type (bits 8-11) --- - uint256 internal constant LENDING_INTEGRATION = 1 << 8; - uint256 internal constant YIELD_BEARING = 1 << 9; - uint256 internal constant CROSS_CHAIN = 1 << 10; - uint256 internal constant AGGREGATOR = 1 << 11; -} diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 20da0d63..5c75d16e 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -20,7 +20,7 @@ import { PairClassFeeAssignment, PairClassFeeClear } from "../src/interfaces/IV4FeePolicy.sol"; -import {HookFeeFlags} from "../src/libraries/HookFeeFlags.sol"; +import {HookFeeFlags} from "./utils/HookFeeFlags.sol"; import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; import { MockFeeClassifiedHook, diff --git a/test/utils/HookFeeFlags.sol b/test/utils/HookFeeFlags.sol new file mode 100644 index 00000000..745238a1 --- /dev/null +++ b/test/utils/HookFeeFlags.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +/// @title HookFeeFlags (test scaffolding) +/// @notice Arbitrary named bits used by the flag-rule tests to construct mock hook +/// self-reports and `FlagRule.requiredFlags`. +/// @dev NOT shipped with the protocol. The policy ascribes no meaning to any bit — it +/// only checks that a rule's `requiredFlags` are a subset of the hook's returned +/// `uint256`. These constants exist purely so the tests read clearly; their specific +/// bit positions are irrelevant beyond being distinct. The real-world flag vocabulary +/// is documented in `docs/V4FeePolicy-governance-guide.md`, not enumerated onchain. +library HookFeeFlags { + uint256 internal constant TAKES_SWAP_SURPLUS = 1 << 0; + uint256 internal constant STABLE_PAIR = 1 << 4; + uint256 internal constant ORACLE_BASED = 1 << 5; + uint256 internal constant YIELD_BEARING = 1 << 9; +} From 83bd154c8778da79e65b7b54b66ab25bf28e4557 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 5 Jun 2026 17:56:45 -0400 Subject: [PATCH 25/26] docs(v4): document safe policy deployment ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly deployed V4FeePolicy returns zero for every pool until configured, and triggerFeeUpdate is permissionless, so anyone can pin zero protocol fees during the window between wiring a new policy into the adapter and configuring it. Document that setPolicy must be the last step of an atomic deploy → configure → setPolicy → trigger batch, since getFee keeps using the prior policy until setPolicy lands. Also note the policy feeSetter is independent of the adapter's and must be set by the owner before any config call. --- docs/V4FeePolicy-governance-guide.md | 36 +++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/V4FeePolicy-governance-guide.md b/docs/V4FeePolicy-governance-guide.md index 8afeb251..7aa6e3f4 100644 --- a/docs/V4FeePolicy-governance-guide.md +++ b/docs/V4FeePolicy-governance-guide.md @@ -255,6 +255,40 @@ triggerFeeUpdate(key) --- +## Deploying or replacing a policy + +A freshly deployed `V4FeePolicy` is **empty**: the constructor only sets `POOL_MANAGER`. +`feeSetter`, `defaultFee`, the fee buckets, and the flag rules all start at zero, so +`computeFee` returns **0 for every pool** until governance has configured it. Because +`triggerFeeUpdate` is permissionless, anyone can push that zero into the PoolManager for +any pool during the window between wiring the policy in and configuring it. + +Avoid the window entirely by making `adapter.setPolicy(newPolicy)` the **last** step. +Until `setPolicy` lands, `adapter.getFee` keeps using the previous policy (or returns the +existing pool overrides), so an unconfigured new policy can do no harm while it is being +set up. Sequence the whole rollout as a single atomic governance batch: + +```text +1. deploy V4FeePolicy(poolManager) // owner = deployer +2. policy.setFeeSetter(govFeeSetter) // owner — required before any config call +3. policy.setDefaultFee / setFeeBuckets / // feeSetter — full configuration + setFlagRules / setHookFamily / ... +4. adapter.setPolicy(policy) // owner — flips reads to the new policy +5. adapter.batchTriggerFeeUpdate(keys) // push the new fees onchain +``` + +Steps 1–3 are observable but inert: no pool reads the new policy until step 4. If steps +4 and 5 cannot share one atomic batch with 1–3, at minimum keep 4 strictly after 3 — a +zero pushed in the gap is not permanent and is corrected by re-triggering the affected +pools, but a fully-configured policy at the moment of `setPolicy` avoids the issue +outright. + +> The new policy's `feeSetter` is independent of the adapter's and starts at zero. The +> policy `owner` (the deployer) must call `policy.setFeeSetter` (step 2) before any +> `onlyFeeSetter` configuration call will succeed. + +--- + ## Footguns 1. **Config ≠ live fee** — Changing policy or overrides does not update PoolManager until `triggerFeeUpdate` (anyone can call). @@ -264,7 +298,7 @@ triggerFeeUpdate(key) 5. **Pair ordering** — `setPairClassFee` requires `currency0 < currency1` (sorted addresses). 6. **Dynamic LP fee** — Do not rely on `key.fee` for amount on dynamic pools; assign a family and use family/pair defaults. 7. **Return-delta hooks** — Address bits 0–3 force classified path unless you `setHookFamily`. `beforeSwap`-only hooks (bit 7, etc.) stay on native math by default. -8. **Policy swap** — `adapter.setPolicy(newPolicy)` requires retrigger on all pools; consider batch. +8. **Policy swap** — `adapter.setPolicy(newPolicy)` requires retrigger on all pools; consider batch. A freshly deployed policy returns **zero everywhere** until configured, and `triggerFeeUpdate` is permissionless — configure fully *before* `setPolicy` (see [Deploying or replacing a policy](#deploying-or-replacing-a-policy)). --- From 31aa2a0b647f2128fb56c7ce249bbdb3259e6ad4 Mon Sep 17 00:00:00 2001 From: Chris Cashwell Date: Fri, 5 Jun 2026 18:01:13 -0400 Subject: [PATCH 26/26] fix(v4): guard native-math fee branch against dynamic-fee keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Governance can force a pool to family 255 (native math) via setHookFamily or a flag rule — a documented capability. For a dynamic-fee pool that path fed key.fee (the 0x800000 DYNAMIC_FEE_FLAG sentinel, not an LP fee) into the bucket curve, which silently clamped the protocol fee to MAX_PROTOCOL_FEE. The auto-resolution gate in _resolveFamily already excludes dynamic pools, but the forced paths bypassed it. Guard the native-math branch in computeFee: dynamic-fee keys fall through to defaultFee instead of bucket pricing. The pairClassFees[pair][255] override check still runs first, so explicit governance intent for a dynamic pair is honored. Also reject familyId 0 in setPairClassFee / clearPairClassFee: computeFee returns defaultFee for family 0 before the pairClassFees lookup, so a family-0 override was silent dead storage a fee-setter could mistake for active config. Regression tests: dynamic pool forced to 255 -> defaultFee (not clamped); pair override still wins; family-0 pair set/clear revert InvalidFamilyId. --- snapshots/V4FeeAdapterForkTest.json | 4 +-- snapshots/V4FeeAdapterTest.json | 12 ++++---- src/feeAdapters/V4FeePolicy.sol | 14 +++++++-- test/V4FeeAdapter.t.sol | 46 ++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json index 0a694516..0cf5f805 100644 --- a/snapshots/V4FeeAdapterForkTest.json +++ b/snapshots/V4FeeAdapterForkTest.json @@ -1,6 +1,6 @@ { - "fork: batchTriggerFeeUpdate 3 pools": "98416", + "fork: batchTriggerFeeUpdate 3 pools": "99022", "fork: collect 2 currencies": "99567", "fork: collect single currency": "62947", - "fork: triggerFeeUpdate single pool": "58123" + "fork: triggerFeeUpdate single pool": "58325" } \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json index 04b708b8..ba1b535f 100644 --- a/snapshots/V4FeeAdapterTest.json +++ b/snapshots/V4FeeAdapterTest.json @@ -1,12 +1,12 @@ { - "adapter.batchTriggerFeeUpdate - two pools": "80457", + "adapter.batchTriggerFeeUpdate - two pools": "80861", "adapter.collect - single currency": "58078", "adapter.getFee - pool override hit": "3330", "adapter.setPoolOverride": "48093", - "adapter.triggerFeeUpdate - single pool": "58067", - "policy.batchClearPairClassFee": "33112", + "adapter.triggerFeeUpdate - single pool": "58269", + "policy.batchClearPairClassFee": "33148", "policy.batchSetHookFamily": "72791", - "policy.batchSetPairClassFee": "76564", + "policy.batchSetPairClassFee": "76610", "policy.computeFee - classified family default": "7928", "policy.computeFee - classified flag-rule self-report": "17486", "policy.computeFee - classified griefing hook -> defaultFee": "7723", @@ -14,7 +14,7 @@ "policy.computeFee - classified unclassified -> defaultFee": "7723", "policy.computeFee - flag-rule multi-flag match": "17486", "policy.computeFee - flag-rule single flag match": "17486", - "policy.computeFee - static native math buckets": "11149", + "policy.computeFee - static native math buckets": "11351", "policy.computeFee - static native math pair class fee": "5938", "policy.setFamilyDefault": "47850", "policy.setFeeBuckets - 1 bucket": "71511", @@ -22,5 +22,5 @@ "policy.setFlagRules - 32 rules (max)": "1499897", "policy.setFlagRules - two rules": "138137", "policy.setHookFamily": "47601", - "policy.setPairClassFee": "49453" + "policy.setPairClassFee": "49476" } \ No newline at end of file diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol index 0e5af60e..8edf735b 100644 --- a/src/feeAdapters/V4FeePolicy.sol +++ b/src/feeAdapters/V4FeePolicy.sol @@ -126,8 +126,14 @@ contract V4FeePolicy is IV4FeePolicy, Owned { uint24 stored = pairClassFees[ph][family]; if (stored != 0) return _decodeFee(stored); - // Native math: use fee buckets. - if (family == NATIVE_MATH_FAMILY_ID) return _computeStaticNativeMathFee(key.fee); + // Native math: use fee buckets. Dynamic-fee keys carry the 0x800000 sentinel, not an + // LP fee, so they cannot be priced from buckets — fall through to defaultFee. A dynamic + // pool only reaches family 255 when governance forces it (setHookFamily / a flag rule); + // the auto-resolution gate in _resolveFamily already excludes dynamic pools. + if (family == NATIVE_MATH_FAMILY_ID) { + if (key.fee.isDynamicFee()) return _decodeFee(defaultFee); + return _computeStaticNativeMathFee(key.fee); + } // Fall through to familyDefaults. uint24 famDefault = familyDefaults[family]; @@ -320,6 +326,9 @@ contract V4FeePolicy is IV4FeePolicy, Owned { function _setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) internal { + // family 0 is never read by computeFee (it returns defaultFee before the pairClassFees + // lookup), so a family-0 override would be silent dead storage. + if (familyId == UNCLASSIFIED_FAMILY_ID) revert InvalidFamilyId(); if (currency0 >= currency1) revert CurrenciesOutOfOrder(); if (feeValue != 0) _validateFee(feeValue); bytes32 ph = _pairHash(currency0, currency1); @@ -329,6 +338,7 @@ contract V4FeePolicy is IV4FeePolicy, Owned { } function _clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) internal { + if (familyId == UNCLASSIFIED_FAMILY_ID) revert InvalidFamilyId(); if (currency0 >= currency1) revert CurrenciesOutOfOrder(); bytes32 ph = _pairHash(currency0, currency1); delete pairClassFees[ph][familyId]; diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol index 5c75d16e..11f74d73 100644 --- a/test/V4FeeAdapter.t.sol +++ b/test/V4FeeAdapter.t.sol @@ -1056,6 +1056,36 @@ contract V4FeeAdapterTest is Test { assertEq(policy.computeFee(dynamicKey), FEE_100); } + function test_computeFee_dynamicFee_forcedToNativeFamily_fallsThroughToDefault() public { + // Regression (audit item 1): governance can force a pool to family 255 via + // setHookFamily. For a dynamic-fee pool that means key.fee == DYNAMIC_FEE_FLAG + // (0x800000) would be fed into the bucket curve, which clamps to MAX_PROTOCOL_FEE. + // The native-math branch must instead fall through to defaultFee for dynamic keys. + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setDefaultFee(FEE_100); + // dynamicKey.hooks == address(0); force its (hook-keyed) family to native math. + policy.setHookFamily(address(dynamicKey.hooks), policy.NATIVE_MATH_FAMILY_ID()); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); // defaultFee, not bucket-clamped + assertTrue(policy.computeFee(dynamicKey) != FEE_1000); // not MAX_PROTOCOL_FEE + } + + function test_computeFee_dynamicFee_forcedToNativeFamily_pairOverrideStillWins() public { + // The dynamic-key guard must not shadow an explicit pairClassFees[pair][255] + // override: that check runs before the native-math branch. + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(address(dynamicKey.hooks), nativeFamily); + policy.setPairClassFee(dynamicKey.currency0, dynamicKey.currency1, nativeFamily, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_200); // explicit pair override honored + } + function testFuzz_computeFee_staticNativeMath_buckets(uint24 lpFee) public { lpFee = uint24(bound(lpFee, 0, LPFeeLibrary.MAX_LP_FEE)); vm.prank(feeSetter); @@ -1511,6 +1541,20 @@ contract V4FeeAdapterTest is Test { policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, (1001 << 12) | 500); } + function test_setPairClassFee_revertsFamilyZero() public { + // Regression (audit item 1): pairClassFees[pair][0] is never read by computeFee + // (family 0 returns defaultFee first), so a family-0 override would be dead storage. + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 0, FEE_200); + } + + function test_clearPairClassFee_revertsFamilyZero() public { + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + vm.prank(feeSetter); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, 0); + } + function test_batchSetPairClassFee_success() public { bytes32 ph = _pairHash(); uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); @@ -1686,7 +1730,7 @@ contract V4FeeAdapterTest is Test { function test_clearPairClassFee_revertsCurrenciesOutOfOrder() public { vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); vm.prank(feeSetter); - policy.clearPairClassFee(standardKey.currency1, standardKey.currency0, 0); + policy.clearPairClassFee(standardKey.currency1, standardKey.currency0, 1); } // ============ Integration: Full Waterfall ============