diff --git a/snapshots/V3FeeAdapterTest.json b/snapshots/V3FeeAdapterTest.json index ffe21f9..4b69036 100644 --- a/snapshots/V3FeeAdapterTest.json +++ b/snapshots/V3FeeAdapterTest.json @@ -1,6 +1,6 @@ { - "batchTriggerFeeUpdate_allLeaves": "277996773", - "triggerFeeUpdate_0": "57728", - "triggerFeeUpdate_4500": "57704", - "triggerFeeUpdate_8999": "55136" + "batchTriggerFeeUpdate_allLeaves": "298521523", + "triggerFeeUpdate_0": "60216", + "triggerFeeUpdate_4500": "60217", + "triggerFeeUpdate_8999": "57524" } \ No newline at end of file diff --git a/src/feeAdapters/V3FeeAdapter.sol b/src/feeAdapters/V3FeeAdapter.sol index 381d8bd..b4dcad0 100644 --- a/src/feeAdapters/V3FeeAdapter.sol +++ b/src/feeAdapters/V3FeeAdapter.sol @@ -35,6 +35,13 @@ contract V3FeeAdapter is IV3FeeAdapter, Owned { /// @inheritdoc IV3FeeAdapter mapping(uint24 feeTier => uint8 defaultFeeValue) public defaultFees; + /// @inheritdoc IV3FeeAdapter + mapping(address pool => uint8 feeOverride) public poolFeeOverrides; + + /// @notice Sentinel value stored in poolFeeOverrides to represent "override to 0" + /// @dev We use 255 because it's an invalid fee value (both nibbles = 15, outside valid range) + uint8 internal constant OVERRIDE_TO_ZERO = type(uint8).max; + /// @return The fee tiers that are enabled on the factory. Iterable so that the protocol fee for /// pools of the same pair can be activated with the same merkle proof. /// @dev Returns four enabled fee tiers: 100, 500, 3000, 10000. May return more if more are @@ -100,18 +107,27 @@ contract V3FeeAdapter is IV3FeeAdapter, Owned { /// @inheritdoc IV3FeeAdapter function setDefaultFeeByFeeTier(uint24 feeTier, uint8 defaultFeeValue) external onlyFeeSetter { require(_feeTierExists(feeTier), InvalidFeeTier()); - // Extract the two 4-bit values - uint8 feeProtocol0 = defaultFeeValue % 16; - uint8 feeProtocol1 = defaultFeeValue >> 4; - // Validate both values match pool requirements: must be 0 or in range [4, 10] - require( - (feeProtocol0 == 0 || (feeProtocol0 >= 4 && feeProtocol0 <= 10)) - && (feeProtocol1 == 0 || (feeProtocol1 >= 4 && feeProtocol1 <= 10)), - InvalidFeeValue() - ); + _validateFeeValue(defaultFeeValue); defaultFees[feeTier] = defaultFeeValue; } + /// @inheritdoc IV3FeeAdapter + function overridePoolFee(address pool, uint8 feeOverride) external onlyFeeSetter { + _validateFeeValue(feeOverride); + // Store sentinel value if overriding to 0, otherwise store the actual value + poolFeeOverrides[pool] = feeOverride == 0 ? OVERRIDE_TO_ZERO : feeOverride; + emit PoolFeeOverrideSet(pool, feeOverride); + + // Update the pool's fee immediately if the pool is initialized + _setProtocolFee(pool, IUniswapV3Pool(pool).fee()); + } + + /// @inheritdoc IV3FeeAdapter + function removePoolFeeOverride(address pool) external onlyFeeSetter { + delete poolFeeOverrides[pool]; + emit PoolFeeOverrideRemoved(pool); + } + /// @inheritdoc IV3FeeAdapter function setFeeSetter(address newFeeSetter) external onlyOwner { feeSetter = newFeeSetter; @@ -164,20 +180,48 @@ contract V3FeeAdapter is IV3FeeAdapter, Owned { } } - /// @notice Sets the protocol fee for a specific pool based on its fee tier + /// @notice Sets the protocol fee for a specific pool based on its fee tier or override /// @dev Only sets the fee for initialized pools (sqrtPriceX96 != 0) - /// The feeValue encodes both fee0 (lower 4 bits) and fee1 (upper 4 bits) + /// If pool has an override, uses the override value; otherwise uses the default for the fee + /// tier The feeValue encodes both fee0 (lower 4 bits) and fee1 (upper 4 bits) /// @param pool The address of the Uniswap V3 pool - /// @param feeTier The fee tier of the pool, used to look up the default fee value + /// @param feeTier The fee tier of the pool, used to look up the default fee value if no override + /// exists function _setProtocolFee(address pool, uint24 feeTier) internal { // Check if pool is initialized by verifying sqrtPriceX96 is non-zero (uint160 sqrtPriceX96,,,,,,) = IUniswapV3Pool(pool).slot0(); if (sqrtPriceX96 == 0) return; // Pool exists but not initialized, skip - uint8 feeValue = defaultFees[feeTier]; + uint8 feeValue = _getEffectiveFee(pool, feeTier); IUniswapV3PoolOwnerActions(pool).setFeeProtocol(feeValue % 16, feeValue >> 4); } + /// @notice Returns the effective fee for a pool, considering overrides + /// @param pool The pool address + /// @param feeTier The fee tier of the pool (used for default lookup) + /// @return The fee value to apply (0 if sentinel, override if set, otherwise default) + function _getEffectiveFee(address pool, uint24 feeTier) internal view returns (uint8) { + uint8 poolFeeOverride = poolFeeOverrides[pool]; + if (poolFeeOverride == 0) return defaultFees[feeTier]; + if (poolFeeOverride == OVERRIDE_TO_ZERO) return 0; + return poolFeeOverride; + } + + /// @notice Validates that a fee value meets protocol requirements + /// @dev Both 4-bit fee values must be 0 or in the range [4, 10] + /// @param feeValue The packed fee value to validate (token1Fee << 4 | token0Fee) + function _validateFeeValue(uint8 feeValue) internal pure { + // Extract the two 4-bit values + uint8 feeProtocol0 = feeValue % 16; + uint8 feeProtocol1 = feeValue >> 4; + // Validate both values match pool requirements: must be 0 or in range [4, 10] + require( + (feeProtocol0 == 0 || (feeProtocol0 >= 4 && feeProtocol0 <= 10)) + && (feeProtocol1 == 0 || (feeProtocol1 >= 4 && feeProtocol1 <= 10)), + InvalidFeeValue() + ); + } + /// @notice Computes a double hash of token addresses for Merkle tree verification /// @dev Performs keccak256(abi.encode(keccak256(abi.encode(token0, token1)))) /// Uses assembly for gas optimization diff --git a/src/interfaces/IV3FeeAdapter.sol b/src/interfaces/IV3FeeAdapter.sol index c17e1d7..b012f90 100644 --- a/src/interfaces/IV3FeeAdapter.sol +++ b/src/interfaces/IV3FeeAdapter.sol @@ -20,6 +20,15 @@ interface IV3FeeAdapter { /// requirements. error InvalidFeeValue(); + /// @notice Emitted when a pool-specific fee override is set + /// @param pool The pool address the override applies to + /// @param feeOverride The override fee value (packed as token1Fee << 4 | token0Fee) + event PoolFeeOverrideSet(address indexed pool, uint8 feeOverride); + + /// @notice Emitted when a pool-specific fee override is removed + /// @param pool The pool address the override was removed from + event PoolFeeOverrideRemoved(address indexed pool); + /// @notice The input parameters for the collection. struct CollectParams { /// @param pool The pool to collect fees from. @@ -105,6 +114,23 @@ interface IV3FeeAdapter { /// inclusive interval [4, 10]. The fee value is packed (token1Fee << 4 | token0Fee) function setDefaultFeeByFeeTier(uint24 feeTier, uint8 defaultFeeValue) external; + /// @notice Sets a fee override for a specific pool, bypassing the fee tier default + /// @dev Only callable by `feeSetter`. The override immediately updates the pool's fee. + /// @param pool The address of the pool to set the override for + /// @param feeOverride The fee override value (packed as token1Fee << 4 | token0Fee) + function overridePoolFee(address pool, uint8 feeOverride) external; + + /// @notice Removes a fee override for a specific pool, reverting to fee tier defaults + /// @dev Only callable by `feeSetter`. Does not immediately update the pool's fee. + /// @param pool The address of the pool to remove the override from + function removePoolFeeOverride(address pool) external; + + /// @notice Returns the stored fee override for a specific pool + /// @dev Returns 0 if no override, 255 (sentinel) if override to 0, otherwise the override value + /// @param pool The pool address to query + /// @return feeOverride The stored override value + function poolFeeOverrides(address pool) external view returns (uint8 feeOverride); + /// @notice Triggers a fee update for a single pool with merkle proof verification. /// @param pool The pool address to update the fee for. /// @param merkleProof The merkle proof corresponding to the set merkle root. diff --git a/test/V3FeeAdapter.t.sol b/test/V3FeeAdapter.t.sol index bd42a6d..edf85f9 100644 --- a/test/V3FeeAdapter.t.sol +++ b/test/V3FeeAdapter.t.sol @@ -624,4 +624,221 @@ contract V3FeeAdapterTest is ProtocolFeesTestBase { if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA); return IV3FeeAdapter.Pair({token0: tokenA, token1: tokenB}); } + + // ==================== Pool Fee Override Tests ==================== + + function test_overridePoolFee_success() public { + uint8 overrideFee = 7 << 4 | 6; // fee1=7, fee0=6 + + vm.prank(feeSetter); + vm.expectEmit(true, false, false, true); + emit IV3FeeAdapter.PoolFeeOverrideSet(pool, overrideFee); + feeAdapter.overridePoolFee(pool, overrideFee); + + // Verify override is stored + assertEq(feeAdapter.poolFeeOverrides(pool), overrideFee); + + // Verify pool fee was updated immediately + assertEq(_getProtocolFees(pool), overrideFee); + } + + function test_overridePoolFee_overridesDefaultFee() public { + uint8 defaultFee = 10 << 4 | 10; + uint8 overrideFee = 5 << 4 | 5; + + // Set default fee for the fee tier + vm.startPrank(feeSetter); + feeAdapter.setDefaultFeeByFeeTier(3000, defaultFee); + + // Set override for specific pool + feeAdapter.overridePoolFee(pool, overrideFee); + vm.stopPrank(); + + // Verify pool has override fee, not default + assertEq(_getProtocolFees(pool), overrideFee); + + // Trigger fee update via merkle proof and verify override is still used + bytes32[] memory leaves = new bytes32[](2); + leaves[0] = _hashLeaf(pool); + leaves[1] = _hashLeaf(pool1); + bytes32 root = merkle.getRoot(leaves); + + vm.prank(feeSetter); + feeAdapter.setMerkleRoot(root); + + bytes32[] memory proof = merkle.getProof(leaves, 0); + feeAdapter.triggerFeeUpdate(pool, proof); + + // Override should still be in effect + assertEq(_getProtocolFees(pool), overrideFee); + } + + function test_overridePoolFee_revertsUnauthorized() public { + address unauthorized = makeAddr("unauthorized"); + vm.prank(unauthorized); + vm.expectRevert(IV3FeeAdapter.Unauthorized.selector); + feeAdapter.overridePoolFee(pool, 0x55); + } + + function test_overridePoolFee_revertsInvalidFeeValue() public { + uint8 invalidFee = 15 << 4 | 15; // Both values > 10 + + vm.prank(feeSetter); + vm.expectRevert(IV3FeeAdapter.InvalidFeeValue.selector); + feeAdapter.overridePoolFee(pool, invalidFee); + } + + function test_overridePoolFee_allowsZeroFee() public { + // Zero fee should be valid (disables protocol fee) + uint8 zeroFee = 0; + uint8 OVERRIDE_TO_ZERO = type(uint8).max; // sentinel value + + vm.prank(feeSetter); + feeAdapter.overridePoolFee(pool, zeroFee); + + // Stored as sentinel value, but effective fee is 0 + assertEq(feeAdapter.poolFeeOverrides(pool), OVERRIDE_TO_ZERO); + assertEq(_getProtocolFees(pool), zeroFee); + } + + function test_overridePoolFee_skipsUninitializedPool() public { + // Create uninitialized pool + MockERC20 token2 = new MockERC20("Token2", "TKN2", 18); + address uninitializedPool = factory.createPool(address(token2), address(mockToken1), 3000); + + uint8 overrideFee = 6 << 4 | 6; + + // Should not revert, just skip setting the fee + vm.prank(feeSetter); + feeAdapter.overridePoolFee(uninitializedPool, overrideFee); + + // Override should be stored + assertEq(feeAdapter.poolFeeOverrides(uninitializedPool), overrideFee); + + // Pool fee should still be 0 (uninitialized) + (,,,,, uint8 poolFees,) = IUniswapV3Pool(uninitializedPool).slot0(); + assertEq(poolFees, 0); + } + + function test_removePoolFeeOverride_success() public { + uint8 overrideFee = 7 << 4 | 6; + + vm.startPrank(feeSetter); + feeAdapter.overridePoolFee(pool, overrideFee); + + // Verify override is set + assertEq(feeAdapter.poolFeeOverrides(pool), overrideFee); + + vm.expectEmit(true, false, false, false); + emit IV3FeeAdapter.PoolFeeOverrideRemoved(pool); + feeAdapter.removePoolFeeOverride(pool); + vm.stopPrank(); + + // Verify override is removed (back to 0 means no override) + assertEq(feeAdapter.poolFeeOverrides(pool), 0); + } + + function test_removePoolFeeOverride_revertsUnauthorized() public { + address unauthorized = makeAddr("unauthorized"); + vm.prank(unauthorized); + vm.expectRevert(IV3FeeAdapter.Unauthorized.selector); + feeAdapter.removePoolFeeOverride(pool); + } + + function test_triggerFeeUpdate_usesDefaultAfterOverrideRemoved() public { + uint8 defaultFee = 10 << 4 | 10; + uint8 overrideFee = 5 << 4 | 5; + + // Setup merkle tree + bytes32[] memory leaves = new bytes32[](2); + leaves[0] = _hashLeaf(pool); + leaves[1] = _hashLeaf(pool1); + bytes32 root = merkle.getRoot(leaves); + + vm.startPrank(feeSetter); + feeAdapter.setMerkleRoot(root); + feeAdapter.setDefaultFeeByFeeTier(3000, defaultFee); + + // Set override + feeAdapter.overridePoolFee(pool, overrideFee); + assertEq(_getProtocolFees(pool), overrideFee); + + // Remove override + feeAdapter.removePoolFeeOverride(pool); + vm.stopPrank(); + + // Trigger fee update - should use default now + bytes32[] memory proof = merkle.getProof(leaves, 0); + feeAdapter.triggerFeeUpdate(pool, proof); + + assertEq(_getProtocolFees(pool), defaultFee); + } + + function test_fuzz_overridePoolFee(uint8 feeValue) public { + uint8 OVERRIDE_TO_ZERO = type(uint8).max; + vm.startPrank(feeSetter); + + uint8 feeProtocol0 = feeValue % 16; + uint8 feeProtocol1 = feeValue >> 4; + bool isValidFeeValue = (feeProtocol0 == 0 || (feeProtocol0 >= 4 && feeProtocol0 <= 10)) + && (feeProtocol1 == 0 || (feeProtocol1 >= 4 && feeProtocol1 <= 10)); + + if (!isValidFeeValue) { + vm.expectRevert(IV3FeeAdapter.InvalidFeeValue.selector); + feeAdapter.overridePoolFee(pool, feeValue); + } else { + feeAdapter.overridePoolFee(pool, feeValue); + // If feeValue is 0, it's stored as sentinel; otherwise stored as-is + uint8 expectedStored = feeValue == 0 ? OVERRIDE_TO_ZERO : feeValue; + assertEq(feeAdapter.poolFeeOverrides(pool), expectedStored); + } + + vm.stopPrank(); + } + + function test_overridePoolFee_canUpdateExistingOverride() public { + uint8 firstOverride = 5 << 4 | 5; + uint8 secondOverride = 8 << 4 | 8; + + vm.startPrank(feeSetter); + feeAdapter.overridePoolFee(pool, firstOverride); + assertEq(_getProtocolFees(pool), firstOverride); + + feeAdapter.overridePoolFee(pool, secondOverride); + assertEq(_getProtocolFees(pool), secondOverride); + vm.stopPrank(); + } + + function test_triggerFeeUpdate_byPair_respectsOverride() public { + uint8 defaultFee3000 = 10 << 4 | 10; + uint8 defaultFee10000 = 8 << 4 | 8; + uint8 overrideFee = 5 << 4 | 5; + + // Setup merkle tree for the pair + bytes32[] memory leaves = new bytes32[](2); + leaves[0] = _hashLeaf(pool); + leaves[1] = _hashLeaf(pool1); + bytes32 root = merkle.getRoot(leaves); + + vm.startPrank(feeSetter); + feeAdapter.setMerkleRoot(root); + feeAdapter.setDefaultFeeByFeeTier(3000, defaultFee3000); + feeAdapter.setDefaultFeeByFeeTier(10_000, defaultFee10000); + + // Set override only for pool (3000 fee tier) + feeAdapter.overridePoolFee(pool, overrideFee); + vm.stopPrank(); + + // Trigger fee update by pair + (address _token0, address _token1) = address(mockToken) < address(mockToken1) + ? (address(mockToken), address(mockToken1)) + : (address(mockToken1), address(mockToken)); + + bytes32[] memory proof = merkle.getProof(leaves, 0); + feeAdapter.triggerFeeUpdate(_token0, _token1, proof); + + // pool should have override, pool1 should have default + assertEq(_getProtocolFees(pool), overrideFee); + assertEq(_getProtocolFees(pool1), defaultFee10000); + } }