RebateFi Hook

First Flight #53
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

Owner Can Front-Run Swaps by Changing Fees

Description

The normal behavior should provide users with predictable fees when they submit swap transactions. Users should be able to rely on current fee settings when making swap decisions.

The ChangeFee() function allows the owner to modify buy and sell fees instantly without any delay or notice period. A malicious or compromised owner could monitor the mempool for large swaps and front-run them by increasing fees just before the swap executes, extracting unexpected value from users.

function ChangeFee(
bool _isBuyFee,
uint24 _buyFee,
bool _isSellFee,
uint24 _sellFee
) external onlyOwner {
@> if(_isBuyFee) buyFee = _buyFee; // ❌ Instant fee change, no delay
@> if(_isSellFee) sellFee = _sellFee; // ❌ Can be changed right before user's swap
}

Attack Scenario:

  1. User submits large sell transaction expecting 0.3% fee (3000 basis points)

  2. Owner sees transaction in mempool

  3. Owner front-runs with ChangeFee(false, 0, true, 50000) to set 5% fee

  4. User's swap executes with 5% fee instead of expected 0.3%

  5. Owner immediately sets fee back to normal

  6. User loses 4.7% more than expected

Risk

Likelihood:

  • Owner has unrestricted ability to change fees at any time

  • Public mempools make pending transactions visible to owner

  • No timelock or delay mechanism protects users

  • Large swaps create financial incentive for fee manipulation

  • Owner EOA could be compromised or act maliciously

  • Automated monitoring tools make mempool watching trivial

  • High-value swaps occur regularly in DeFi

Impact:

  • Users lose funds to unexpected fee increases

  • Complete loss of trust in protocol

  • Users cannot rely on displayed fees in frontends

  • Slippage protection may not catch fee manipulation

  • Arbitrage opportunities for owner at users' expense

  • Protocol reputation destroyed

  • Users migrate to competing protocols

  • Potential legal liability for fraud

  • MEV searchers could collaborate with owner

  • Liquidity providers may exit due to manipulation concerns

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "forge-std/console.sol";
contract FeeFrontrunningTest is Test {
address owner = address(0x1);
address user = address(0x2);
address maliciousOwner = address(0x3);
uint24 public buyFee = 0;
uint24 public sellFee = 3000; // 0.3%
// Simulated swap function
function swap(uint256 amount, bool isBuy) public view returns (uint256 fee, uint256 received) {
uint24 appliedFee = isBuy ? buyFee : sellFee;
fee = (amount * appliedFee) / 1000000;
received = amount - fee;
}
// Owner can change fee
function changeFee(uint24 _buyFee, uint24 _sellFee) public {
buyFee = _buyFee;
sellFee = _sellFee;
}
function test_FrontrunningAttack_LargeSwap() public {
console.log("\n=== Front-Running Attack Scenario ===");
console.log("User wants to sell 100,000 ReFi tokens");
uint256 swapAmount = 100000 ether;
// Step 1: User checks current fee
console.log("\nStep 1: User checks fee on frontend");
console.log("Current sell fee:", sellFee, "basis points (0.3%)");
// Calculate expected output
(uint256 normalFee, uint256 normalReceived) = swap(swapAmount, false);
console.log("Expected fee:", normalFee / 1 ether, "tokens");
console.log("Expected to receive:", normalReceived / 1 ether, "tokens");
// Step 2: User submits transaction to mempool
console.log("\nStep 2: User transaction enters mempool");
console.log("Transaction awaiting confirmation...");
// Step 3: Owner sees large swap in mempool
console.log("\nStep 3: Owner monitors mempool and sees large swap");
console.log("Owner decides to front-run");
// Step 4: Owner front-runs by increasing fee
console.log("\nStep 4: Owner front-runs with fee increase");
uint24 attackFee = 50000; // 5%
changeFee(0, attackFee);
console.log("Owner sets sell fee to:", attackFee, "basis points (5%)");
// Step 5: User's swap executes with higher fee
console.log("\nStep 5: User's swap executes");
(uint256 actualFee, uint256 actualReceived) = swap(swapAmount, false);
console.log("Actual fee charged:", actualFee / 1 ether, "tokens");
console.log("Actually received:", actualReceived / 1 ether, "tokens");
// Calculate loss
uint256 extraFee = actualFee - normalFee;
console.log("\n=== Attack Impact ===");
console.log("Expected fee:", normalFee / 1 ether, "tokens (0.3%)");
console.log("Actual fee:", actualFee / 1 ether, "tokens (5%)");
console.log("Extra loss:", extraFee / 1 ether, "tokens");
console.log("Loss percentage:", (extraFee * 100) / swapAmount, "%");
// Step 6: Owner resets fee to hide attack
console.log("\nStep 6: Owner resets fee to normal");
changeFee(0, 3000);
console.log("Sell fee reset to:", sellFee, "basis points");
console.log("Attack complete, evidence hidden");
// Verify the attack
assertGt(actualFee, normalFee, "User paid more than expected");
assertEq(extraFee, 4700 ether, "User lost 4700 extra tokens");
}
function test_MultipleVictimsAttack() public {
console.log("\n=== Multiple Victims Attack ===");
uint256 victim1Amount = 50000 ether;
uint256 victim2Amount = 75000 ether;
uint256 victim3Amount = 100000 ether;
// Normal fees for baseline
changeFee(0, 3000);
(uint256 v1NormalFee, ) = swap(victim1Amount, false);
(uint256 v2NormalFee, ) = swap(victim2Amount, false);
(uint256 v3NormalFee, ) = swap(victim3Amount, false);
uint256 totalNormalFees = v1NormalFee + v2NormalFee + v3NormalFee;
console.log("Total normal fees (0.3%):", totalNormalFees / 1 ether, "tokens");
// Attack: Increase fee
changeFee(0, 50000);
(uint256 v1AttackFee, ) = swap(victim1Amount, false);
(uint256 v2AttackFee, ) = swap(victim2Amount, false);
(uint256 v3AttackFee, ) = swap(victim3Amount, false);
uint256 totalAttackFees = v1AttackFee + v2AttackFee + v3AttackFee;
console.log("Total attack fees (5%):", totalAttackFees / 1 ether, "tokens");
uint256 extraProfit = totalAttackFees - totalNormalFees;
console.log("Owner's extra profit:", extraProfit / 1 ether, "tokens");
console.log("Victims' combined loss:", extraProfit / 1 ether, "tokens");
}
function test_TimingWindow() public {
console.log("\n=== Attack Timing Window ===");
// Simulate block timestamps
uint256 userSubmitTime = block.timestamp;
uint256 ownerFrontrunTime = block.timestamp;
uint256 swapExecutionTime = block.timestamp + 12; // Next block
console.log("Block n: User submits swap tx");
console.log("Block n: Owner sees tx in mempool");
console.log("Block n: Owner submits ChangeFee with higher gas");
console.log("Block n+1: Owner's ChangeFee executes first");
console.log("Block n+1: User's swap executes with new fee");
console.log("\nAttack window: Entire duration in mempool");
console.log("Protection: NONE - instant fee changes allowed");
}
function test_ComparisonWithTimelock() public {
console.log("\n=== With vs Without Timelock ===");
console.log("\nCurrent Implementation (NO TIMELOCK):");
console.log("✗ Fee changes instant");
console.log("✗ Users cannot react");
console.log("✗ No warning period");
console.log("✗ Owner can front-run");
console.log("✗ No protection mechanism");
console.log("\nWith Timelock:");
console.log("✓ Fee changes delayed (e.g., 24 hours)");
console.log("✓ Users can withdraw before change");
console.log("✓ Transparent upcoming changes");
console.log("✓ Cannot front-run (change not immediate)");
console.log("✓ Trust through time");
}
function test_RealWorldImpact() public {
console.log("\n=== Real-World Impact Analysis ===");
// Scenario: Protocol has $10M TVL, $1M daily volume
uint256 dailyVolume = 1000000 ether;
// Normal operation (0.3% on half of volume that's sells)
uint256 sellVolume = dailyVolume / 2;
uint256 normalFees = (sellVolume * 3000) / 1000000;
console.log("\nNormal daily fees (0.3% on sells):", normalFees / 1 ether, "tokens");
// Attack day (increase to 5% for a few large swaps)
uint256 largeSwap1 = 100000 ether;
uint256 largeSwap2 = 150000 ether;
uint256 attackProfit = ((largeSwap1 + largeSwap2) * (50000 - 3000)) / 1000000;
console.log("\nAttack day extra profit:", attackProfit / 1 ether, "tokens");
console.log("Percentage of daily volume: ~", (attackProfit * 100) / dailyVolume, "%");
console.log("\nUser Trust Impact:");
console.log("- Users realize they can't trust displayed fees");
console.log("- Large traders avoid the protocol");
console.log("- TVL decreases as users exit");
console.log("- Protocol reputation destroyed");
console.log("- Competing protocols gain market share");
}
function test_SlippageBypassPotential() public {
console.log("\n=== Slippage Protection Bypass ===");
uint256 swapAmount = 100000 ether;
console.log("User sets 1% slippage protection");
console.log("Swap amount:", swapAmount / 1 ether, "tokens");
// Normal expected output
changeFee(0, 3000);
(, uint256 expectedOutput) = swap(swapAmount, false);
console.log("Expected output:", expectedOutput / 1 ether, "tokens");
uint256 minOutput = expectedOutput * 99 / 100; // 1% slippage
console.log("Minimum accepted:", minOutput / 1 ether, "tokens");
// Attack with 2% fee instead of 0.3%
changeFee(0, 20000);
(, uint256 attackOutput) = swap(swapAmount, false);
console.log("\nWith 2% fee:");
console.log("Actual output:", attackOutput / 1 ether, "tokens");
console.log("Within slippage?", attackOutput >= minOutput ? "YES" : "NO");
if (attackOutput >= minOutput) {
console.log("\n❌ Attack bypasses slippage protection!");
console.log("User accepts the swap thinking it's normal slippage");
console.log("But actually paid extra fees due to manipulation");
}
}
}

Running the PoC:

forge test --match-contract FeeFrontrunningTest -vv

Expected Output:

[PASS] test_FrontrunningAttack_LargeSwap()
=== Front-Running Attack Scenario ===
User wants to sell 100,000 ReFi tokens
Step 1: User checks fee on frontend
Current sell fee: 3000 basis points (0.3%)
Expected fee: 300 tokens
Expected to receive: 99700 tokens
Step 2: User transaction enters mempool
Transaction awaiting confirmation...
Step 3: Owner monitors mempool and sees large swap
Owner decides to front-run
Step 4: Owner front-runs with fee increase
Owner sets sell fee to: 50000 basis points (5%)
Step 5: User's swap executes
Actual fee charged: 5000 tokens
Actually received: 95000 tokens
=== Attack Impact ===
Expected fee: 300 tokens (0.3%)
Actual fee: 5000 tokens (5%)
Extra loss: 4700 tokens
Loss percentage: 4 %
Step 6: Owner resets fee to normal
Sell fee reset to: 3000 basis points
Attack complete, evidence hidden

Recommended Mitigation

Option 1: Implement Timelock (Recommended)

/// @notice Pending fee change structure
struct PendingFeeChange {
uint24 newBuyFee;
uint24 newSellFee;
uint256 effectiveTime;
bool isPending;
}
/// @notice Timelock duration for fee changes (24 hours)
uint256 public constant FEE_CHANGE_DELAY = 24 hours;
/// @notice Pending fee change
PendingFeeChange public pendingFeeChange;
/// @notice Event emitted when fee change is scheduled
event FeeChangeScheduled(uint24 newBuyFee, uint24 newSellFee, uint256 effectiveTime);
/// @notice Event emitted when fee change is applied
event FeeChangeApplied(uint24 newBuyFee, uint24 newSellFee);
/// @notice Schedule a fee change (executes after delay)
function scheduleFeeChange(uint24 _newBuyFee, uint24 _newSellFee) external onlyOwner {
require(_newBuyFee <= MAX_FEE, "Buy fee exceeds maximum");
require(_newSellFee <= MAX_FEE, "Sell fee exceeds maximum");
pendingFeeChange = PendingFeeChange({
newBuyFee: _newBuyFee,
newSellFee: _newSellFee,
effectiveTime: block.timestamp + FEE_CHANGE_DELAY,
isPending: true
});
emit FeeChangeScheduled(_newBuyFee, _newSellFee, pendingFeeChange.effectiveTime);
}
/// @notice Apply the pending fee change after timelock expires
function applyFeeChange() external {
require(pendingFeeChange.isPending, "No pending fee change");
require(block.timestamp >= pendingFeeChange.effectiveTime, "Timelock not expired");
buyFee = pendingFeeChange.newBuyFee;
sellFee = pendingFeeChange.newSellFee;
emit FeeChangeApplied(buyFee, sellFee);
delete pendingFeeChange; // Clear pending change
}
/// @notice Cancel a pending fee change
function cancelFeeChange() external onlyOwner {
require(pendingFeeChange.isPending, "No pending fee change");
delete pendingFeeChange;
}

Option 2: Maximum Fee Change Per Transaction

/// @notice Maximum fee change allowed per update (1% = 10000 basis points)
uint24 public constant MAX_FEE_CHANGE = 10000;
/// @notice Updates fees with rate limiting
function ChangeFee(
bool _isBuyFee,
uint24 _buyFee,
bool _isSellFee,
uint24 _sellFee
) external onlyOwner {
if (_isBuyFee) {
require(_buyFee <= MAX_FEE, "Buy fee exceeds maximum");
uint24 change = _buyFee > buyFee ? _buyFee - buyFee : buyFee - _buyFee;
require(change <= MAX_FEE_CHANGE, "Fee change too large");
buyFee = _buyFee;
}
if (_isSellFee) {
require(_sellFee <= MAX_FEE, "Sell fee exceeds maximum");
uint24 change = _sellFee > sellFee ? _sellFee - sellFee : sellFee - _sellFee;
require(change <= MAX_FEE_CHANGE, "Fee change too large");
sellFee = _sellFee;
}
}

Option 3: Multi-Signature Governance

import "@openzeppelin/contracts/access/AccessControl.sol";
contract ReFiSwapRebateHook is BaseHook, AccessControl {
bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");
uint8 public constant REQUIRED_APPROVALS = 2;
struct FeeChangeProposal {
uint24 buyFee;
uint24 sellFee;
mapping(address => bool) approvals;
uint8 approvalCount;
}
FeeChangeProposal public pendingProposal;
function proposeFeeChange(uint24 _buyFee, uint24 _sellFee)
external
onlyRole(FEE_MANAGER_ROLE)
{
pendingProposal.buyFee = _buyFee;
pendingProposal.sellFee = _sellFee;
pendingProposal.approvalCount = 0;
}
function approveFeeChange() external onlyRole(FEE_MANAGER_ROLE) {
require(!pendingProposal.approvals[msg.sender], "Already approved");
pendingProposal.approvals[msg.sender] = true;
pendingProposal.approvalCount++;
if (pendingProposal.approvalCount >= REQUIRED_APPROVALS) {
buyFee = pendingProposal.buyFee;
sellFee = pendingProposal.sellFee;
}
}
}

Recommendation: Combine Options 1 and 2

Implement both timelock and rate limiting for maximum protection:

  • Timelock prevents instant front-running

  • Rate limiting prevents drastic sudden changes

  • Users have time to react to fee changes

  • Transparent and predictable fee policy

Additional Recommendations:

  1. Emit events for all fee changes

  2. Display pending fee changes in frontend

  3. Allow users to cancel pending swaps if fees will change

  4. Consider using governance for fee changes

  5. Document fee change policy clearly

Updates

Lead Judging Commences

chaossr Lead Judge 8 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!