Description
-
In Uniswap v4 swaps, zeroForOne indicates the direction: swapping token0 → token1 when true, and token1 → token0 when false. A hook that applies asymmetric fees must correctly detect whether the swap results in receiving ReFi (i.e., a buy of ReFi) or spending ReFi (i.e., a sell of ReFi), independent of whether ReFi is currency0 or currency1.
-
The function _isReFiBuy(PoolKey key, bool zeroForOne) returns zeroForOne when ReFi is currency0, and returns !zeroForOne when ReFi is currency1. This logic classifies directions backwards:
-
If ReFi is currency0, a zeroForOne == true swap spends ReFi (selling ReFi → token1), but _isReFiBuy returns true (misclassifies as a buy).
-
If ReFi is currency1, a zeroForOne == true swap receives ReFi (buying token1 with token0), but _isReFiBuy returns false (misclassifies as a sell).
function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
@> return zeroForOne;
} else {
@> return !zeroForOne;
}
}
Risk
Likelihood: High
-
Occurs for all swaps on pools using the hook, whenever ReFi is involved—no special conditions required.
-
Triggers consistently across both token orderings, because classification is inverted by construction.
Impact: High
-
Buys are charged sell fees, and sells may be charged buy (often zero) fees, defeating the intended asymmetric fee model.
-
Traders can route through pools with misclassification to avoid intended sell fees (pay near‑zero), or face unexpected high fees on buys, harming user experience and protocol revenue.
Proof of Concept
event ReFiBought(address indexed buyer, uint256 amount);
event ReFiSold(address indexed seller, uint256 amount, uint256 fee);
function testIsReFiBoughtEventEmitted() public {
uint256 ethAmount = 0.01 ether;
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(ethAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
});
vm.expectEmit(true, false, false, false);
emit ReFiBought(user1, ethAmount);
swapRouter.swap{value: ethAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
}
function testIsReFiSoldEventEmitted() public {
uint256 reFiAmount = 0.01 ether;
vm.startPrank(user1);
reFiToken.approve(address(swapRouter), type(uint256).max);
SwapParams memory params = SwapParams({
zeroForOne: false,
amountSpecified: -int256(reFiAmount),
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
});
vm.expectEmit(true, false, false, false);
emit ReFiSold(user1, reFiAmount, (reFiAmount * 3000) / 1_000_00);
swapRouter.swap(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
}
-
When running tests with flag -vvv, it can be seen which event was actually emitted and that proves the wrong if branch was chosen
-
due to inverted Buy/Sell detection. Output of the first test shows the wrong event emitted:
│ │ │ ├─ [72263] PoolManager::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x212224D2F2d262cd093eE13240ca4873fcCBbA3C, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x77FA63897A066eC7420C058933659856671C7080 }), SwapParams({ zeroForOne: true, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ ├─ [7225] ReFiSwapRebateHook::beforeSwap(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x212224D2F2d262cd093eE13240ca4873fcCBbA3C, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x77FA63897A066eC7420C058933659856671C7080 }), SwapParams({ zeroForOne: true, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 4295128740 [4.295e9] }), 0x)
│ │ │ │ │ ├─ emit ReFiSold(seller: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount: 10000000000000000 [1e16], fee: 300000000000000 [3e14])
│ │ │ │ │ └─ ← [Return] 0x575e24b4, 0, 4197304 [4.197e6]
│ │ │ │ ├─ emit Swap(id: 0x4e482eb50b6d21bc05ee464210aa9fabab3c9a6362ef2803ca803ed0784c299c, sender: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount0: -10000000000000000 [-1e16], amount1: 9967023479114567 [9.967e15], sqrtPriceX96: 79204509126055898735909960289 [7.92e28], liquidity: 33385024970969944913 [3.338e19], tick: -6, fee: 3000)
│ │ │ │ └─ ← [Return] -3402823669209384634633746074317682114550032976520885433 [-3.402e54]
│ │ │ ├─ [44251] PoolManager::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x212224D2F2d262cd093eE13240ca4873fcCBbA3C, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x77FA63897A066eC7420C058933659856671C7080 }), SwapParams({ zeroForOne: false, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 1461446703485210103287273052203988822378723970341 [1.461e48] }), 0x)
│ │ │ │ ├─ [6251] ReFiSwapRebateHook::beforeSwap(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x212224D2F2d262cd093eE13240ca4873fcCBbA3C, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x77FA63897A066eC7420C058933659856671C7080 }), SwapParams({ zeroForOne: false, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 1461446703485210103287273052203988822378723970341 [1.461e48] }), 0x)
│ │ │ │ │ ├─ emit ReFiBought(buyer: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount: 10000000000000000 [1e16])
│ │ │ │ │ └─ ← [Return] 0x575e24b4, 0, 4194304 [4.194e6]
│ │ │ │ ├─ emit Swap(id: 0x4e482eb50b6d21bc05ee464210aa9fabab3c9a6362ef2803ca803ed0784c299c, sender: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], amount0: 9997005541990553 [9.997e15], amount1: -10000000000000000 [-1e16], sqrtPriceX96: 79251894161187818237737846152 [7.925e28], liquidity: 33385024970969944913 [3.338e19], tick: 5, fee: 0)
│ │ │ │ └─ ← [Return] 3401804707950284987846385279791586160233259406626586624 [3.401e54]
Recommended Mitigation
function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
- if (IsReFiCurrency0) {
- return zeroForOne;
- } else {
- return !zeroForOne;
- }
+ if (IsReFiCurrency0) {
+ // If ReFi is token0, buying ReFi means oneForZero (zeroForOne == false)
+ return !zeroForOne;
+ } else {
+ // If ReFi is token1, buying ReFi means zeroForOne (token0 -> token1)
+ return zeroForOne;
+ }
}