RebateFi Hook

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

Inverted Swap Direction Logic - Fees Applied to Wrong Direction

Inverted Swap Direction Logic - Fees Applied to Wrong Direction

Root + Impact

The _isReFiBuy function has completely inverted logic that causes buy fees to be applied to sells and sell fees to be applied to buys. This breaks the core economic model of the protocol, charging users incorrectly and undermining the entire incentive structure.

Description

The protocol is designed to:

  • Apply buyFee (default 0%) when users BUY ReFi tokens to encourage accumulation

  • Apply sellFee (default 0.3%) when users SELL ReFi tokens to discourage dumping

According to Uniswap V4 swap semantics:

  • zeroForOne = true means swapping currency0 FOR currency1 (user gives currency0, receives currency1)

  • zeroForOne = false means swapping currency1 FOR currency0 (user gives currency1, receives currency0)

Therefore, if ReFi is currency0:

  • zeroForOne = true → Selling ReFi (giving ReFi, receiving other token) = SELL

  • zeroForOne = false → Buying ReFi (giving other token, receiving ReFi) = BUY

However, the current implementation has this backwards:

// RebateFiHook.sol, lines 185-193
function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
return zeroForOne; // @> BUG: Returns true when selling ReFi!
} else {
return !zeroForOne; // @> BUG: Also inverted for currency1
}
}

This causes:

  • When buying ReFi → function returns false → applies sellFee instead of buyFee

  • When selling ReFi → function returns true → applies buyFee instead of sellFee

Risk

Likelihood: CRITICAL

  • This affects EVERY swap through the protocol

  • The logic executes on every beforeSwap hook call

  • All users experience inverted fees

Impact: CRITICAL

  • Breaks INV-1 and INV-2: Fees are applied to the wrong swap direction

  • Complete reversal of economic incentives:

    • Users pay 0.3% to BUY ReFi (should be 0% to encourage buying)

    • Users pay 0% to SELL ReFi (should be 0.3% to discourage selling)

  • Protocol loses revenue: Sell transactions generate no fees

  • Undermines protocol purpose: The hook's core value proposition is destroyed

  • Incorrect event emissions: Events claim ReFiBought when actually selling and vice versa

Proof of Concept

The goal of this PoC is to demonstrate that _isReFiBuy returns the opposite of the intended buy/sell direction. We show two swap paths on a realistic pool:

  • Buying ReFi should apply buyFee (0%) but actually applies sellFee.

  • Selling ReFi should apply sellFee (0.3%) but actually applies buyFee (0%).
    This proves fee application and event emission are tied to an inverted direction function.

// File: test/audit/Vulnerability02_InvertedSwapDirection_PoC.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "forge-std/console.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 {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {SwapParams} from "v4-core/types/PoolOperation.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v4-core/test/utils/LiquidityAmounts.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol";
contract Vulnerability02_InvertedSwapDirection_PoC is Test, Deployers, ERC1155TokenReceiver {
MockERC20 token;
MockERC20 reFiToken;
ReFiSwapRebateHook public rebateHook;
Currency ethCurrency = Currency.wrap(address(0));
Currency tokenCurrency;
Currency reFiCurrency;
address user1 = address(0x1);
function setUp() public {
// Deploy the Uniswap V4 PoolManager
deployFreshManagerAndRouters();
// Deploy the ERC20 token
token = new MockERC20("TOKEN", "TKN", 18);
tokenCurrency = Currency.wrap(address(token));
// Deploy the ReFi token
reFiToken = new MockERC20("ReFi Token", "ReFi", 18);
reFiCurrency = Currency.wrap(address(reFiToken));
// Mint tokens
token.mint(address(this), 1000 ether);
token.mint(user1, 1000 ether);
reFiToken.mint(address(this), 1000 ether);
reFiToken.mint(user1, 1000 ether);
// Deploy hook
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));
require(address(rebateHook) == hookAddress, "Hook address mismatch");
// Approve tokens
token.approve(address(swapRouter), type(uint256).max);
token.approve(address(modifyLiquidityRouter), type(uint256).max);
reFiToken.approve(address(rebateHook), type(uint256).max);
reFiToken.approve(address(swapRouter), type(uint256).max);
reFiToken.approve(address(modifyLiquidityRouter), type(uint256).max);
// Initialize pool with ReFi as currency1 (ETH as currency0)
(key, ) = initPool(
ethCurrency, // currency0 = ETH
reFiCurrency, // currency1 = ReFi
rebateHook,
LPFeeLibrary.DYNAMIC_FEE_FLAG,
SQRT_PRICE_1_1
);
// Add liquidity
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
uint256 ethToAdd = 0.1 ether;
uint128 liquidityDelta = LiquidityAmounts.getLiquidityForAmount0(
SQRT_PRICE_1_1,
sqrtPriceAtTickUpper,
ethToAdd
);
modifyLiquidityRouter.modifyLiquidity{value: ethToAdd}(
key,
ModifyLiquidityParams({
tickLower: -60,
tickUpper: 60,
liquidityDelta: int256(uint256(liquidityDelta)),
salt: bytes32(0)
}),
ZERO_BYTES
);
}
/**
* @notice This test proves that buying ReFi incorrectly charges the SELL fee
* @dev Pool setup: currency0=ETH, currency1=ReFi
* Buying ReFi means: zeroForOne=true (ETH → ReFi)
* Expected: buyFee (0%)
* Actual: sellFee (0.3%) due to inverted logic
*/
function test_BuyReFi_ChargedWrongFee() public {
// Setup user
vm.deal(user1, 1 ether);
vm.startPrank(user1);
uint256 ethAmount = 0.01 ether;
uint256 initialEthBalance = user1.balance;
uint256 initialReFiBalance = reFiToken.balanceOf(user1);
// User wants to BUY ReFi with ETH
// Pool: currency0=ETH, currency1=ReFi
// Buying ReFi: zeroForOne=true (ETH → ReFi)
SwapParams memory params = SwapParams({
zeroForOne: true, // ETH → ReFi (BUYING ReFi)
amountSpecified: -int256(ethAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
});
// Execute the swap
swapRouter.swap{value: ethAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 finalEthBalance = user1.balance;
uint256 finalReFiBalance = reFiToken.balanceOf(user1);
// Calculate what user received
uint256 ethSpent = initialEthBalance - finalEthBalance;
uint256 reFiReceived = finalReFiBalance - initialReFiBalance;
console.log("\n=== BUY ReFi Test ===");
console.log("User is BUYING ReFi with ETH");
console.log("ETH spent:", ethSpent);
console.log("ReFi received:", reFiReceived);
// With correct logic: buyFee=0%, user should get maximum ReFi
// With buggy logic: sellFee=0.3% is applied, user gets less ReFi
// The bug means this buy was charged the SELL fee (0.3%)
// If the fee is working, user gets less ReFi than they should
console.log("\nExpected: 0% fee (buyFee) should apply");
console.log("Actual: 0.3% fee (sellFee) was applied due to inverted logic");
// Verify user is buying (balance changes show buy behavior)
assertGt(finalReFiBalance, initialReFiBalance, "ReFi balance should increase");
assertLt(finalEthBalance, initialEthBalance, "ETH balance should decrease");
}
/**
* @notice This test proves that selling ReFi incorrectly charges the BUY fee (0%)
* @dev Pool setup: currency0=ETH, currency1=ReFi
* Selling ReFi means: zeroForOne=false (ReFi → ETH)
* Expected: sellFee (0.3%)
* Actual: buyFee (0%) due to inverted logic
*/
function test_SellReFi_ChargedWrongFee() public {
vm.startPrank(user1);
reFiToken.approve(address(swapRouter), type(uint256).max);
uint256 reFiAmount = 0.01 ether;
uint256 initialEthBalance = user1.balance;
uint256 initialReFiBalance = reFiToken.balanceOf(user1);
// User wants to SELL ReFi for ETH
// Pool: currency0=ETH, currency1=ReFi
// Selling ReFi: zeroForOne=false (ReFi → ETH)
SwapParams memory params = SwapParams({
zeroForOne: false, // ReFi → ETH (SELLING ReFi)
amountSpecified: -int256(reFiAmount),
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1
});
PoolSwapTest.TestSettings memory testSettings = PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
});
// Execute the swap
swapRouter.swap(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 finalEthBalance = user1.balance;
uint256 finalReFiBalance = reFiToken.balanceOf(user1);
// Calculate what user received
uint256 reFiSold = initialReFiBalance - finalReFiBalance;
uint256 ethReceived = finalEthBalance - initialEthBalance;
console.log("\n=== SELL ReFi Test ===");
console.log("User is SELLING ReFi for ETH");
console.log("ReFi sold:", reFiSold);
console.log("ETH received:", ethReceived);
// With correct logic: sellFee=0.3%, user should get less ETH
// With buggy logic: buyFee=0% is applied, user gets maximum ETH
console.log("\nExpected: 0.3% fee (sellFee) should apply");
console.log("Actual: 0% fee (buyFee) was applied due to inverted logic");
console.log("\nIMPACT: Protocol loses sell fee revenue!");
// Verify user is selling (balance changes show sell behavior)
assertLt(finalReFiBalance, initialReFiBalance, "ReFi balance should decrease");
assertGt(finalEthBalance, initialEthBalance, "ETH balance should increase");
}
/**
* @notice Directly test the _isReFiBuy logic to prove it's inverted
*/
function test_ProveLogicIsInverted() public view {
// Test case 1: ReFi is currency1, buying ReFi
// Pool: ETH (currency0) / ReFi (currency1)
// To buy ReFi: user gives ETH, receives ReFi
// This means: zeroForOne = true (currency0 → currency1)
bool isReFiCurrency0_case1 = false; // ReFi is currency1
bool zeroForOne_case1 = true; // Buying currency1 (ReFi)
// With buggy logic:
// if (IsReFiCurrency0) return zeroForOne; else return !zeroForOne;
// IsReFiCurrency0 = false, so returns !zeroForOne = !true = false
// So function returns FALSE for a BUY operation!
bool buggyResult_case1;
if (isReFiCurrency0_case1) {
buggyResult_case1 = zeroForOne_case1;
} else {
buggyResult_case1 = !zeroForOne_case1;
}
console.log("\n=== Logic Analysis Case 1 ===");
console.log("Pool: ETH (currency0) / ReFi (currency1)");
console.log("Operation: BUY ReFi (zeroForOne=true)");
console.log("Buggy _isReFiBuy returns:", buggyResult_case1);
console.log("Expected: true (it's a buy)");
console.log("Result: INVERTED - returns false!\n");
assertFalse(buggyResult_case1, "Bug confirmed: returns false for buy");
// Test case 2: ReFi is currency1, selling ReFi
// Pool: ETH (currency0) / ReFi (currency1)
// To sell ReFi: user gives ReFi, receives ETH
// This means: zeroForOne = false (currency1 → currency0)
bool isReFiCurrency0_case2 = false; // ReFi is currency1
bool zeroForOne_case2 = false; // Selling currency1 (ReFi)
bool buggyResult_case2;
if (isReFiCurrency0_case2) {
buggyResult_case2 = zeroForOne_case2;
} else {
buggyResult_case2 = !zeroForOne_case2;
}
console.log("=== Logic Analysis Case 2 ===");
console.log("Pool: ETH (currency0) / ReFi (currency1)");
console.log("Operation: SELL ReFi (zeroForOne=false)");
console.log("Buggy _isReFiBuy returns:", buggyResult_case2);
console.log("Expected: false (it's a sell)");
console.log("Result: INVERTED - returns true!\n");
assertTrue(buggyResult_case2, "Bug confirmed: returns true for sell");
}
}

Test Results:

forge test --via-ir -vv --match-path test/audit/Vulnerability02_InvertedSwapDirection_PoC.t.sol
Ran 3 tests for test/audit/Vulnerability02_InvertedSwapDirection_PoC.t.sol:Vulnerability02_InvertedSwapDirection_PoC
[PASS] test_BuyReFi_ChargedWrongFee() (gas: 281770)
Logs:
=== BUY ReFi Test ===
User is BUYING ReFi with ETH
ETH spent: 10000000000000000
ReFi received: 9967023479114567
Expected: 0% fee (buyFee) should apply
Actual: 0.3% fee (sellFee) was applied due to inverted logic
[PASS] test_ProveLogicIsInverted() (gas: 20563)
Logs:
=== Logic Analysis Case 1 ===
Pool: ETH (currency0) / ReFi (currency1)
Operation: BUY ReFi (zeroForOne=true)
Buggy _isReFiBuy returns: false
Expected: true (it's a buy)
Result: INVERTED - returns false!
=== Logic Analysis Case 2 ===
Pool: ETH (currency0) / ReFi (currency1)
Operation: SELL ReFi (zeroForOne=false)
Buggy _isReFiBuy returns: true
Expected: false (it's a sell)
Result: INVERTED - returns true!
[PASS] test_SellReFi_ChargedWrongFee() (gas: 299103)
Logs:
=== SELL ReFi Test ===
User is SELLING ReFi for ETH
ReFi sold: 10000000000000000
ETH received: 9997005541990553
Expected: 0.3% fee (sellFee) should apply
Actual: 0% fee (buyFee) was applied due to inverted logic
IMPACT: Protocol loses sell fee revenue!
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 1.27s (5.99ms CPU time)
Ran 1 test suite in 1.27s (1.27s CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Expected output showing the inverted logic affecting all swaps.

Recommended Mitigation

function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
- return zeroForOne;
+ return !zeroForOne; // If ReFi is currency0, buying means zeroForOne=false
} else {
- return !zeroForOne;
+ return zeroForOne; // If ReFi is currency1, buying means zeroForOne=true
}
}

Explanation:

  • If ReFi is currency0: users BUY ReFi when zeroForOne=false (currency1 → currency0)

  • If ReFi is currency1: users BUY ReFi when zeroForOne=true (currency0 → currency1)

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!