Fee changes via ChangeFee can be backrun, any user can sell ReFi at the new (potentially heavily discounted) sell fee immediately
Description
-
The owner uses ChangeFee to update the global sellFee, often to temporarily grant a very low or zero sell fee as a premium/discount for a specific user or campaign.
-
The function writes the new sellFee directly to storage with no delay or recipient restriction.
-
Because the fee update is effective in the same block (or even same transaction bundle), any searcher/MEV bots or regular users can immediately front-run or back-run the owner’s transaction and sell ReFi at the newly discounted rate before the owner can revert it.
function ChangeFee(bool _isBuyFee, uint24 _buyFee, bool _isSellFee, uint24 _sellFee) external onlyOwner {
if (_isBuyFee) buyFee = _buyFee;
@> if (_isSellFee) sellFee = _sellFee;
}
Risk
Likelihood:
-
Every on-chain fee change is public in the mempool and can be observed in real time
-
Searchers routinely monitor privileged transactions (onlyOwner calls) on popular protocols and hooks
-
Back-running a fee change requires only a simple swap transaction — trivial to execute
Impact:
-
Intended premium discounts (e.g., 0–1 bp sell fee) are exploited by arbitragers instead of the target user/community
-
The project loses significant fee revenue it expected to collect after the promotion
-
Creates unfair economics and erodes trust in the rebate/discount mechanism
Proof of Concept
Add the following code snippet to the RebateFiHookTest.t.sol test file.
This snippet of code is to demonstrate that the user2 can sell ReFi with 100% discount by bypassing the standard fee.
function test_BuyReFi_MultipleUsers() public {
uint256 ethAmount = 0.001 ether;
rebateHook.ChangeFee(true, 0, true, 1);
vm.deal(user1, 1 ether);
vm.startPrank(user1);
uint256 user1InitialReFi = reFiToken.balanceOf(user1);
SwapParams memory params = SwapParams({
zeroForOne: true,
amountSpecified: -int256(ethAmount),
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: ethAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
vm.deal(user2, 1 ether);
vm.startPrank(user2);
uint256 user2InitialReFi = reFiToken.balanceOf(user2);
swapRouter.swap{value: ethAmount}(key, params, testSettings, ZERO_BYTES);
vm.stopPrank();
assertGt(reFiToken.balanceOf(user1), user1InitialReFi, "User1 should have more ReFi");
assertGt(reFiToken.balanceOf(user2), user2InitialReFi, "User2 should have more ReFi");
(, uint24 sellFee) = rebateHook.getFeeConfig();
assertEq(sellFee, 1, "Sell fee should be 1");
}
Recommended Mitigation
Possible mitigation is to add a premium fee registry for specific users. This can be done by adding a mapping of address to uint24 and setting the premium fee for the user.
mapping(address => uint24) public premiumFees;
function ChangeFee(bool _isBuyFee, uint24 _buyFee, bool _isSellFee, uint24 _sellFee) external onlyOwner {
if (_isBuyFee) buyFee = _buyFee;
- if (_isSellFee) sellFee = _sellFee;
+ if (_isSellFee) {
+ sellFee = _sellFee;
+ premiumFees[msg.sender] = _sellFee;
}
}
.
.
.
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 {
+ uint24 premiumFee = premiumFees[sender];
+ if (premiumFee > 0) {
+ fee = premiumFee;
+ } else {
+ fee = sellFee;
+ }
- fee = sellFee;
uint256 feeAmount = (swapAmount * sellFee) / 100000;
emit ReFiSold(sender, swapAmount, feeAmount);
}
return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}