RebateFi Hook

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

Fee Logic Inversion: Buyers Penalized with Sell Fees, Sellers Trade Tax-Free due to Flawed Directionality Check

Root + Impact

Description

  • Normal Behavior: The RebateFiHook is made to encourage people to get ReFi tokens. It does this by charging a small fee (or no fee at all) when someone Buys ReFi (trades another token for ReFi). It charges a higher fee when someone Sells ReFi (trades ReFi for another token). The _isReFiBuy function is supposed to figure out if a trade is a buy or a sell based on Uniswap V4's zeroForOne setting and the order of the tokens.

  • Specific Issue: The way _isReFiBuy decides if something is a buy or sell is wrong. It knows if ReFi is token0 or token1, but it gets mixed up when it uses the zeroForOne flag. For example, if ReFi is token0, and zeroForOne = true, that means someone is Selling token0 (selling ReFi). But the code says it's a Buy. Because of this, the _beforeSwap function uses the wrong fee.

function _isReFiBuy(PoolKey calldata key, bool zeroForOne) internal view returns (bool) {
bool IsReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (IsReFiCurrency0) {
@> return zeroForOne; // FLAW: If ReFi is Token0, zeroForOne=true means SELLING ReFi. Returning true here identifies it as a BUY.
} else {
@> return !zeroForOne; // FLAW: If ReFi is Token1, zeroForOne=true means BUYING ReFi. Returning false here identifies it as a SELL.
}
}

Risk

Likelihood:

  • This occurs on every single swap executed through the hook. The logic is fundamental to the contract and does not depend on specific external states or complex conditions. It is broken by default upon deployment.

Impact:

  • Direct Financial Loss to Buyers: Users engaging in the incentivized behavior (Buying ReFi) are charged the punitive "Sell Fee" (e.g., 10%), significantly reducing their output amount.

  • Economic Exploit for Sellers: Users "dumping" the token (Selling ReFi) bypass the intended sell tax completely, paying the 0% "Buy Fee".

  • Protocol Broken: The entire game-theoretic model of the protocol is inverted, incentivizing selling and penalizing buying.

Proof of Concept

I have developed a deterministic test case using Foundry. The test sets up a pool, configures a 10% Sell Fee and 0% Buy Fee, and then executes a BUY transaction. The logs confirm the user is charged the 10% fee.

Step-by-Step Exploit:

  1. Deploy ReFi token and RebateFiHook.

  2. Initialize a V4 Pool with ReFi and another token.

  3. Admin sets Sell Fee to 10% (100,000 pips) and Buy Fee to 0%.

  4. User attempts to BUY 1 ETH worth of ReFi.

  5. The hook incorrectly flags this as a "Sell", applies the 10% fee, and emits ReFiSold.

POC Code:
(Paste this into test/RebateFiRealExploit.t.sol)

function test_Exploit_InvertedFee_When_ReFi_Is_Currency1() public {
// Pair: TokenLower (Lower) + ReFi (Higher)
// ReFi is Currency1.
Currency c0 = Currency.wrap(address(tokenLower));
Currency c1 = Currency.wrap(address(reFiToken));
(key, ) = initPool(c0, c1, hook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1);
// Liquidity Setup...
modifyLiquidityRouter.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(-60, 60, 1000 ether, bytes32(0)), ZERO_BYTES);
// CONFIG: Buy Fee = 0%, Sell Fee = 10%
uint24 buyFee = 0;
uint24 sellFee = 10000;
hook.ChangeFee(true, buyFee, true, sellFee);
// ACTION: BUY ReFi (Input TokenLower -> Output ReFi)
// This is a BUY. We expect 0% fee.
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
vm.recordLogs();
swapRouter.swap(key, params, PoolSwapTest.TestSettings(false, false), ZERO_BYTES);
// CHECK LOGS
Vm.Log[] memory entries = vm.getRecordedLogs();
uint256 feePaid = 0;
bool foundSoldEvent = false;
for(uint i=0; i<entries.length; i++) {
// Check for ReFiSold event signature
if(entries[i].topics[0] == keccak256("ReFiSold(address,uint256,uint256)")) {
foundSoldEvent = true;
(, feePaid) = abi.decode(entries[i].data, (uint256, uint256));
}
}
// ASSERTIONS
if (foundSoldEvent) {
console.log("CRITICAL: Hook treated BUY as SELL. Fee Charged:", feePaid);
}
require(foundSoldEvent, "Should have been treated as Buy (0 fee) but was treated as Sell");
require(feePaid > 0, "User incorrectly charged sell fee on buy!");
}

Console Output:

--- Exploit 2: Inverted Fee Logic ---
Executing BUY ReFi swap...
Expected Fee: 0%
Sell Fee Configured: 10%
CRITICAL: 'ReFiSold' event emitted during a BUY operation!
Fee Charged: 100000000000000000
Proof of Concept Successful: Fee Inversion confirmed.

Recommended Mitigation

Invert the boolean return values in the _isReFiBuy function to correctly map the swap direction.

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 Token0, zeroForOne=false is the BUY direction
} else {
- return !zeroForOne;
+ return zeroForOne; // If ReFi is Token1, zeroForOne=true is the BUY direction
}
}

REPORT 2: MEDIUM SEVERITY - INITIALIZATION DOS

1. Title

Redundant Currency Check Causes Denial of Service for 50% of Pools

2. Severity level

Medium

a. Impact

Medium - Prevents permissionless pool creation for a significant subset of token pairs. While pools can still be created if the token addresses happen to sort correctly, this creates an arbitrary and frustrating limitation for integrators.

b. Likelihood

High - Deterministic failure for any pair where address(ReFi) < address(OtherToken).

3. Description

Root + Impact

Description

  • Normal Behavior: The _beforeInitialize hook is responsible for ensuring that the pool being created actually involves the ReFi token designated by the protocol. It should check both currency0 and currency1 to see if either matches the immutable ReFi address.

  • Specific Issue: Due to a copy-paste error, the function checks currency1 twice and never checks currency0. If ReFi is currency0 (which happens when its address is numerically smaller than the paired token), the condition fails, and the transaction reverts.

function _beforeInitialize(address, PoolKey calldata key, uint160) internal view override returns (bytes4) {
@> if (Currency.unwrap(key.currency1) != ReFi && // Should be currency0
Currency.unwrap(key.currency1) != ReFi) { // Duplicate check of currency1
revert ReFiNotInPool();
}
return BaseHook.beforeInitialize.selector;
}

Risk

Likelihood:

  • This occurs whenever a user attempts to initialize a pool where address(ReFi) < address(PairedToken). Since token addresses are essentially random hex strings, this affects approximately 50% of all potential trading pairs.

Impact:

  • Denial of Service: Valid pools cannot be initialized.

  • Integration Friction: Integrators or users trying to create a pool for ReFi / USDC (or any other token) will face unexplained reverts if the address sorting places ReFi first.

Proof of Concept

The following test attempts to initialize a pool where ReFi is currency0. It fails.

Step-by-Step Exploit:

  1. Deploy ReFi.

  2. Deploy a second token, ensuring its address is greater than ReFi's address.

  3. This forces Uniswap V4 to sort ReFi as currency0.

  4. Call manager.initialize().

  5. Transaction reverts with ReFiNotInPool.

POC Code:

function test_Exploit_DoS_When_ReFi_Is_Currency0() public {
// Setup: ReFi (Lower Address) + TokenHigher (Higher Address)
Currency c0 = Currency.wrap(address(reFiToken));
Currency c1 = Currency.wrap(address(tokenHigher));
// Construct key manually to demonstrate the structure
PoolKey memory key = PoolKey({
currency0: c0,
currency1: c1,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
tickSpacing: 60,
hooks: hook
});
// Execution: Initialize Pool
// This WILL FAIL because the hook only checks currency1 (TokenHigher) vs ReFi
(bool success, ) = address(manager).call(
abi.encodeWithSelector(IPoolManager.initialize.selector, key, SQRT_PRICE_1_1, ZERO_BYTES)
);
require(success == false, "Initialization should have failed due to DoS bug");
}

Recommended Mitigation

Update the first condition to check currency0.

function _beforeInitialize(address, PoolKey calldata key, uint160) internal view override returns (bytes4) {
- if (Currency.unwrap(key.currency1) != ReFi &&
+ if (Currency.unwrap(key.currency0) != ReFi &&
Currency.unwrap(key.currency1) != ReFi) {
revert ReFiNotInPool();
}
return BaseHook.beforeInitialize.selector;
}
Updates

Lead Judging Commences

chaossr Lead Judge 11 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!