RebateFi Hook

First Flight #53
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: high
Valid

Buy/Sell detection is inverted

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).

// Root cause in the codebase with @> marks to highlight the relevant section
function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
@> return zeroForOne; // <-- should be !zeroForOne (if ReFi is token0, buy occurs on oneForZero)
} else {
@> return !zeroForOne; // <-- should be zeroForOne (if ReFi is token1, buy occurs on 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

  • The bellow two tests will fail because of the wrong (inverted) value of isReFiBuy variable in _beforeSwap function and due to another issue presented in separate report:

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, // ETH -> ReFi
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, // ReFi -> ETH
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]
  • Output of the second test shows the wrong event emitted as well:

│ │ │ ├─ [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

  • Correct the mapping so “buy ReFi” corresponds to receiving ReFi:

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;
+ }
}
Updates

Lead Judging Commences

chaossr Lead Judge 12 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Inverted buy/sell logic when ReFi is currency0, leading to incorrect fee application.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!