Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions snapshots/V3FeeAdapterTest.json
Original file line number Diff line number Diff line change
@@ -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"
}
70 changes: 57 additions & 13 deletions src/feeAdapters/V3FeeAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of hitting the defaultFees path last, we can check that first unless you think it will be most common to have an override value

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 194d05e

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
Expand Down
26 changes: 26 additions & 0 deletions src/interfaces/IV3FeeAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
217 changes: 217 additions & 0 deletions test/V3FeeAdapter.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading