RebateFi Hook

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

ReFiBuy Sell Fees Are Not Enforced; Hook Collects No Protocol Revenue on Sells

Description

  • The hook calculates a sell fee for every ReFi token sale.

  • Instead of enforcing that fee, it returns ZERO_DELTA, so the fee is never collected by the hook.

  • In getHookPermissions function the beforeSwapReturnDelta is false which states the the Return delta feature won't be enforced which makes the fee collection impossible

  • Also the BeforeSwapDeltaLibrary should only be used when selling tokens, but it is used in both the cases

  • Only the LP fee is applied, meaning users do not pay the intended extra protocol fee.

  • The protocol’s sell fee revenue exists only in emitted events, not as actual on-chain transfers.

  • This breaks the economic promise in the docs, as no protocol-level “revenue” is generated from ReFi sells.


// Root cause in the codebase with @> marks to highlight the relevant section
function _beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
bool isReFiBuy = _isReFiBuy(key, params.zeroForOne);
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
uint24 fee;
if (isReFiBuy) {
fee = buyFee;
emit ReFiBought(sender, swapAmount);
} else {
fee = sellFee;
uint256 feeAmount = (swapAmount * sellFee) / 100000;
emit ReFiSold(sender, swapAmount, feeAmount);
}
return (
BaseHook.beforeSwap.selector,
@> BeforeSwapDeltaLibrary.ZERO_DELTA,
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
// issue in getHookPermissions() function
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: true,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
@> beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}

Risk

Likelihood:

  • Every ReFi sell executes through this path; the fee computation is deterministic, and the zero delta is always returned on sells.

Impact:

  • Protocol loses all intended revenue from ReFi sell fees. Users pay only the overridden LP fee, not the additional sell fee that the hook promises. This breaks the protocol's economic model, which depends on higher sell fees to discourage token dumping and fund operations.

Proof of Concept

The test logs show:

  • User's ReFi is debited by exactly 0.01 ReFi (the swap input).

  • Hook's ReFi balance remains 0 (no fee collected).

  • Hook's ETH balance remains 0 (no fee collected).

  • User receives ETH (swap completes normally).

This proves the hook never enforces or collects the sell fee via on-chain balance changes.

function test_SellReFi_NoHookFeeCollected2() public {
// Arrange: ensure default sellFee is set (3000) and hook has no ReFi balance
(, uint24 sellFee) = rebateHook.getFeeConfig();
console.log("Configured sellFee (bps):", sellFee);
assertEq(sellFee, 3000, "Sell fee should be 3000");
uint256 hookReFiStart = reFiToken.balanceOf(address(rebateHook));
console.log("Hook initial ReFi balance:", hookReFiStart);
assertEq(hookReFiStart, 0, "Hook should start with 0 ReFi");
uint256 reFiAmount = 0.01 ether;
console.log("ReFi amount to sell:", reFiAmount);
// Fund / approve user
vm.startPrank(user1);
reFiToken.approve(address(swapRouter), type(uint256).max);
uint256 userReFiBefore = reFiToken.balanceOf(user1);
uint256 userEthBefore = user1.balance;
uint256 hookReFiBefore = reFiToken.balanceOf(address(rebateHook));
uint256 hookEthBefore = address(rebateHook).balance;
console.log("== BEFORE SWAP ==");
console.log("user1 ReFi:", userReFiBefore);
console.log("user1 ETH:", userEthBefore);
console.log("hook ReFi:", hookReFiBefore);
console.log("hook ETH:", hookEthBefore);
// Act: sell ReFi -> ETH (sell path, isReFiBuy == false)
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
});
console.log("Calling swap for user1: selling ReFi for ETH...");
swapRouter.swap(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
// After swap
uint256 userReFiAfter = reFiToken.balanceOf(user1);
uint256 userEthAfter = user1.balance;
uint256 hookReFiAfter = reFiToken.balanceOf(address(rebateHook));
uint256 hookEthAfter = address(rebateHook).balance;
console.log("== AFTER SWAP ==");
console.log("user1 ReFi:", userReFiAfter);
console.log("user1 ETH:", userEthAfter);
console.log("hook ReFi:", hookReFiAfter);
console.log("hook ETH:", hookEthAfter);
// Deltas for easier visual inspection
console.log("== DELTAS ==");
console.log("user1 ReFi delta (before - after):", userReFiBefore - userReFiAfter);
console.log("user1 ETH delta (after - before):", userEthAfter - userEthBefore);
console.log("hook ReFi delta (after - before):", hookReFiAfter - hookReFiBefore);
console.log("hook ETH delta (after - before):", hookEthAfter - hookEthBefore);
// 1) User ReFi debited by exact swap amount (no extra ReFi fee)
assertEq(
userReFiBefore - userReFiAfter,
reFiAmount,
"User should only lose the specified ReFi amount (no extra hook fee)"
);
// 2) Hook did not accumulate any tokens from the swap
assertEq(
hookReFiAfter,
hookReFiBefore,
"Hook should not receive any ReFi from the sell (ZERO_DELTA)"
);
assertEq(
hookEthAfter,
hookEthBefore,
"Hook should not receive any ETH from the sell (ZERO_DELTA)"
);
// 3) Sanity: user did receive some ETH (swap executed)
assertGt(
userEthAfter,
userEthBefore,
"User should receive ETH from selling ReFi"
);
}
/*
///////////////////////////////////////// Output /////////////////////////////////////////////////////
PASS] test_SellReFi_NoHookFeeCollected2() (gas: 230671)
Logs:
Configured sellFee (bps): 3000
Hook initial ReFi balance: 0
ReFi amount to sell: 10000000000000000
== BEFORE SWAP ==
user1 ReFi: 1000000000000000000000
user1 ETH: 0
hook ReFi: 0
hook ETH: 0
Calling swap for user1: selling ReFi for ETH...
== AFTER SWAP ==
user1 ReFi: 999990000000000000000
user1 ETH: 9997005541990553
hook ReFi: 0
hook ETH: 0
== DELTAS ==
user1 ReFi delta (before - after): 10000000000000000
user1 ETH delta (after - before): 9997005541990553
hook ReFi delta (after - before): 0
hook ETH delta (after - before): 0
Traces:
[230671] TestReFiSwapRebateHook::test_SellReFi_NoHookFeeCollected2()
├─ [2894] ReFiSwapRebateHook::getFeeConfig() [staticcall]
│ └─ ← [Return] 0, 3000
├─ [0] console::log("Configured sellFee (bps):", 3000) [staticcall]
│ └─ ← [Stop]
├─ [2825] MockERC20::balanceOf(ReFiSwapRebateHook: [0x5Ea76dced871d32C594E967FA4D07df7832AF080]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("Hook initial ReFi balance:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("ReFi amount to sell:", 10000000000000000 [1e16]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::startPrank(ECRecover: [0x0000000000000000000000000000000000000001])
│ └─ ← [Return]
├─ [25102] MockERC20::approve(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ ├─ emit Approval(owner: ECRecover: [0x0000000000000000000000000000000000000001], spender: PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Return] true
├─ [2825] MockERC20::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ └─ ← [Return] 1000000000000000000000 [1e21]
├─ [825] MockERC20::balanceOf(ReFiSwapRebateHook: [0x5Ea76dced871d32C594E967FA4D07df7832AF080]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("== BEFORE SWAP ==") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ReFi:", 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ETH:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ReFi:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ETH:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("Calling swap for user1: selling ReFi for ETH...") [staticcall]
│ └─ ← [Stop]
├─ [135305] PoolSwapTest::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x2a07706473244BC757E10F2a9E86fB532828afe3, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x5Ea76dced871d32C594E967FA4D07df7832AF080 }), SwapParams({ zeroForOne: false, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 1461446703485210103287273052203988822378723970341 [1.461e48] }), TestSettings({ takeClaims: false, settleUsingBurn: false }), 0x)
│ ├─ [124066] PoolManager::unlock(0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a07706473244bc757e10f2a9e86fb532828afe30000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000005ea76dced871d32c594e967fa4d07df7832af0800000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffdc790d903f0000000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d2500000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ ├─ [121083] PoolSwapTest::unlockCallback(0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a07706473244bc757e10f2a9e86fb532828afe30000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000005ea76dced871d32c594e967fa4d07df7832af0800000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffdc790d903f0000000000000000000000000000fffd8963efd1fc6a506488495d951d5263988d2500000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000)
│ │ │ ├─ [549] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [825] MockERC20::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ │ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ │ ├─ [2825] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 100000000000000000 [1e17]
│ │ │ ├─ [549] PoolManager::exttload(0x10e143d4e19b26f18146d695ea98e08249192e595a44ce1e4b46eb27ca3a2c40) [staticcall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000
│ │ │ ├─ [39751] PoolManager::swap(PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x2a07706473244BC757E10F2a9E86fB532828afe3, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x5Ea76dced871d32C594E967FA4D07df7832AF080 }), SwapParams({ zeroForOne: false, amountSpecified: -10000000000000000 [-1e16], sqrtPriceLimitX96: 1461446703485210103287273052203988822378723970341 [1.461e48] }), 0x)
│ │ │ │ ├─ [4251] ReFiSwapRebateHook::beforeSwap(PoolSwapTest: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], PoolKey({ currency0: 0x0000000000000000000000000000000000000000, currency1: 0x2a07706473244BC757E10F2a9E86fB532828afe3, fee: 8388608 [8.388e6], tickSpacing: 60, hooks: 0x5Ea76dced871d32C594E967FA4D07df7832AF080 }), 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: 0xc14102b82a2458dd50349db85930df8ba510fe545d122ef7b9779a0055e4c8e6, 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]
│ │ │ ├─ [549] PoolManager::exttload(0xc6bd81b73d1fd76b6ab7b2a3e675f602f162633118b2ec0b74d6456669dd8579) [staticcall]
│ │ │ │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000002384393c25e099
│ │ │ ├─ [825] MockERC20::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ │ │ │ └─ ← [Return] 1000000000000000000000 [1e21]
│ │ │ ├─ [825] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ └─ ← [Return] 100000000000000000 [1e17]
│ │ │ ├─ [549] PoolManager::exttload(0x10e143d4e19b26f18146d695ea98e08249192e595a44ce1e4b46eb27ca3a2c40) [staticcall]
│ │ │ │ └─ ← [Return] 0xffffffffffffffffffffffffffffffffffffffffffffffffffdc790d903f0000
│ │ │ ├─ [2497] PoolManager::sync(MockERC20: [0x2a07706473244BC757E10F2a9E86fB532828afe3])
│ │ │ │ ├─ [825] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ │ └─ ← [Return] 100000000000000000 [1e17]
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [9736] MockERC20::transferFrom(ECRecover: [0x0000000000000000000000000000000000000001], PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 10000000000000000 [1e16])
│ │ │ │ ├─ emit Transfer(from: ECRecover: [0x0000000000000000000000000000000000000001], to: PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], value: 10000000000000000 [1e16])
│ │ │ │ └─ ← [Return] true
│ │ │ ├─ [4024] PoolManager::settle()
│ │ │ │ ├─ [825] MockERC20::balanceOf(PoolManager: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ │ │ │ │ └─ ← [Return] 110000000000000000 [1.1e17]
│ │ │ │ └─ ← [Return] 10000000000000000 [1e16]
│ │ │ ├─ [37209] PoolManager::take(0x0000000000000000000000000000000000000000, ECRecover: [0x0000000000000000000000000000000000000001], 9997005541990553 [9.997e15])
│ │ │ │ ├─ [3000] PRECOMPILES::ecrecover{value: 9997005541990553}(0x)
│ │ │ │ │ └─ ← [Return] 0x
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Return] 0x0000000000000000002384393c25e099ffffffffffffffffffdc790d903f0000
│ │ └─ ← [Return] 0x0000000000000000002384393c25e099ffffffffffffffffffdc790d903f0000
│ └─ ← [Return] 3401804707950284987846385279791586160233259406626586624 [3.401e54]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [825] MockERC20::balanceOf(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
│ └─ ← [Return] 999990000000000000000 [9.999e20]
├─ [825] MockERC20::balanceOf(ReFiSwapRebateHook: [0x5Ea76dced871d32C594E967FA4D07df7832AF080]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("== AFTER SWAP ==") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ReFi:", 999990000000000000000 [9.999e20]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ETH:", 9997005541990553 [9.997e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ReFi:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ETH:", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("== DELTAS ==") [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ReFi delta (before - after):", 10000000000000000 [1e16]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("user1 ETH delta (after - before):", 9997005541990553 [9.997e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ReFi delta (after - before):", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("hook ETH delta (after - before):", 0) [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]

Recommended Mitigation

  • To enforce a real hook-level sell fee, modify _beforeSwap to return a non-zero BeforeSwapDelta on sells

  • In the function getHookPermission set the value of beforeSwapReturnDelta to true to enforce this to the hook

function _beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
bool isReFiBuy = _isReFiBuy(key, params.zeroForOne);
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
uint24 fee;
int256 specifiedDelta = 0;
int256 unspecifiedDelta = 0;
if (isReFiBuy) {
fee = buyFee;
// For buys, no hook fee, just override LP fee
} else {
fee = sellFee;
// Calculate fee amount in token units
uint256 feeAmount = (swapAmount * fee) / 100000;
// Hook receives the fee (positive delta)
specifiedDelta = int256(feeAmount);
}
// Pack the deltas into BeforeSwapDelta format
BeforeSwapDelta delta = BeforeSwapDeltaLibrary.toBeforeSwapDelta(specifiedDelta, unspecifiedDelta);
return (
BaseHook.beforeSwap.selector,
delta,
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
// Chnages in getHookPermission function
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: true,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: true, // turn this to true
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
Updates

Lead Judging Commences

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

Ovveride fee

still not sure about this

Support

FAQs

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

Give us feedback!