RebateFi Hook

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

Inverted Fee Logic Causes Users to Pay Wrong Fees (Logic Error + Financial Loss)

Description:
The _isReFiBuy function in RebateFiHook.sol incorrectly determines the swap direction, leading to the application of the wrong fee.
When ReFi is currency0, zeroForOne (true) represents selling currency0 (ReFi) for currency1. This is a SELL operation.
However, the code returns zeroForOne directly:

if (IsReFiCurrency0) {
return zeroForOne; // Returns true for Sell
}

If zeroForOne is true (Sell), _isReFiBuy returns true.
Consequently, in _beforeSwap:

if (isReFiBuy) { // True (actually Sell)
fee = buyFee; // 0%
} else {
fee = sellFee; // 0.3%
}

So, users SELLING ReFi pay the Buy Fee (0%), and users BUYING ReFi pay the Sell Fee (0.3%).
The logic is similarly inverted when ReFi is currency1.

Impact:

  • Users buying ReFi are charged the sell fee (intended to be higher/premium), discouraging accumulation.

  • Users selling ReFi are charged the buy fee (intended to be zero/low), encouraging dumping.

  • This completely defeats the economic purpose of the protocol (incentivizing holding/buying).

    Proof of Concept:
    The following test demonstrates that buying ReFi triggers the Sell logic (emitting ReFiSold and charging sellFee).

function test_Bug_InvertedFeeLogic() public {
// Setup Pool: ETH (0) / ReFi (1)
// ETH -> ReFi (Buy) means zeroForOne = true.
// Expect ReFiSold event (Sell Logic) instead of ReFiBought
// This confirms the logic thinks we are selling when we are buying.
vm.expectEmit(true, true, true, true);
emit ReFiSwapRebateHook.ReFiSold(address(swapRouter), amountToSwap, (amountToSwap * 3000) / 100000);
swapRouter.swap{value: amountToSwap}(
key,
SwapParams({
zeroForOne: true, // Buy ReFi
amountSpecified: -int256(amountToSwap),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
}

Test file

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {ReFiSwapRebateHook} from "../src/RebateFiHook.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
contract AuditReproduction is Test, Deployers {
using CurrencyLibrary for Currency;
MockERC20 token;
MockERC20 reFiToken;
ReFiSwapRebateHook public rebateHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency reFiCurrency;
Currency tokenCurrency;
address user1 = address(0x1);
function setUp() public {
deployFreshManagerAndRouters();
reFiToken = new MockERC20("ReFi Token", "ReFi", 18);
reFiCurrency = Currency.wrap(address(reFiToken));
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
reFiToken.mint(address(this), 1000 ether);
reFiToken.mint(user1, 1000 ether);
token.mint(address(this), 1000 ether);
bytes memory creationCode = type(ReFiSwapRebateHook).creationCode;
bytes memory constructorArgs = abi.encode(manager, address(reFiToken));
uint160 flags = uint160(
Hooks.BEFORE_INITIALIZE_FLAG |
Hooks.AFTER_INITIALIZE_FLAG |
Hooks.BEFORE_SWAP_FLAG
);
(address hookAddress, bytes32 salt) = HookMiner.find(
address(this),
flags,
creationCode,
constructorArgs
);
rebateHook = new ReFiSwapRebateHook{salt: salt}(manager, address(reFiToken));
reFiToken.approve(address(rebateHook), type(uint256).max);
reFiToken.approve(address(swapRouter), type(uint256).max);
reFiToken.approve(address(modifyLiquidityRouter), type(uint256).max);
}
function test_Bug_InvertedFeeLogic() public {
(key, ) = initPool(
ethCurrency,
reFiCurrency,
rebateHook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
modifyLiquidityRouter.modifyLiquidity{value: 10 ether}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: 10 ether,
salt: bytes32(0)
}),
ZERO_BYTES
);
vm.deal(user1, 10 ether);
vm.startPrank(user1);
uint256 amountToSwap = 1 ether;
vm.expectEmit(true, true, true, true);
emit ReFiSwapRebateHook.ReFiSold(address(swapRouter), amountToSwap, (amountToSwap * 3000) / 100000);
swapRouter.swap{value: amountToSwap}(
key,
SwapParams({
zeroForOne: true,
amountSpecified: -int256(amountToSwap),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
vm.stopPrank();
}
}



Recommended Mitigation:
Correct the logic in _isReFiBuy.
If ReFi is currency0, zeroForOne (0->1) is a SELL. So return !zeroForOne.
If ReFi is currency1, zeroForOne (0->1) is a BUY. So return zeroForOne.

function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
- return zeroForOne;
+ return !zeroForOne;
} else {
- return !zeroForOne;
+ 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!