RebateFi Hook

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

All Low submission at once

[L-1] Missing Event Emission on Fee Change (Transparency)

Description:
The ChangeFee function updates critical protocol parameters (buyFee and sellFee) but does not emit an event.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {ReFiSwapRebateHook} from "../src/RebateFiHook.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
contract L1_MissingEventTest is Test {
ReFiSwapRebateHook public rebateHook;
MockERC20 reFiToken;
IPoolManager manager;
function setUp() public {
manager = new PoolManager(address(0));
reFiToken = new MockERC20("ReFi Token", "ReFi", 18);
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));
}
function test_L1_ChangeFee_DoesNotEmitEvent() public {
// We want to verify that NO event is emitted when changing fees.
// We can record logs and check if any match a hypothetical FeeChanged event,
// or simply demonstrate that the function executes without emitting the expected event.
// In this case, we just call it. If we were testing a fix, we would expectEmit.
// Since we are reproducing the bug (missing event), we can't "expect" a missing event easily
// other than asserting no logs were emitted if we filter for them.
vm.recordLogs();
rebateHook.ChangeFee(true, 100, true, 100);
Vm.Log[] memory entries = vm.getRecordedLogs();
// We expect NO events to be emitted by the hook for this action.
// The contract only has ReFiBought, ReFiSold, TokensWithdrawn events.
// It does NOT have a FeeChanged event.
assertEq(entries.length, 0, "Should not emit any events (bug reproduction)");
}
}

Impact:
Off-chain monitoring tools and users cannot track fee changes easily, reducing protocol transparency.

Recommended Mitigation:
Define and emit a FeeChanged event.

event FeeChanged(bool isBuyFee, uint24 newBuyFee, bool isSellFee, uint24 newSellFee);

[L-3] Unbounded Minting Capability (Centralization Risk)

Description:
The ReFi token contract allows the owner to mint unlimited tokens.

function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {ReFi} from "../src/ReFi.sol";
contract L3_UnboundedMintingTest is Test {
ReFi public reFiToken;
address owner = address(this);
address user = address(0x1);
function setUp() public {
reFiToken = new ReFi();
}
function test_L3_OwnerCanMintUnlimited() public {
uint256 initialSupply = reFiToken.totalSupply();
// Mint a huge amount
uint256 hugeAmount = 1_000_000_000 ether;
reFiToken.mint(user, hugeAmount);
assertEq(reFiToken.balanceOf(user), hugeAmount);
assertEq(reFiToken.totalSupply(), initialSupply + hugeAmount);
// Mint again
reFiToken.mint(user, hugeAmount);
assertEq(reFiToken.balanceOf(user), hugeAmount * 2);
// This confirms the owner has unbounded minting power, which is the centralization risk.
}
}

Impact:
The owner can dilute all holders or manipulate the price, posing a significant centralization risk.

Recommended Mitigation:
Remove the mint function after initial distribution, or add a hard cap / timelock.

[L-4] Potential Stuck ETH (Locked Funds)

Description:
The RebateFiHook contract does not have a receive() or fallback() function, nor a method to withdraw ETH.
While the hook is not designed to hold ETH, it could receive ETH via selfdestruct from another contract.
The withdrawTokens function only supports IERC20 transfers.

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {ReFiSwapRebateHook} from "../src/RebateFiHook.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {PoolManager} from "v4-core/PoolManager.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
import {Hooks} from "v4-core/libraries/Hooks.sol";
contract ForceSendETH {
constructor(address payable _to) payable {
selfdestruct(_to);
}
}
contract L4_StuckETHTest is Test {
ReFiSwapRebateHook public rebateHook;
MockERC20 reFiToken;
IPoolManager manager;
function setUp() public {
manager = new PoolManager(address(0));
reFiToken = new MockERC20("ReFi Token", "ReFi", 18);
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));
}
function test_L4_CannotWithdrawETH() public {
// Force send ETH to the hook
uint256 amount = 1 ether;
new ForceSendETH{value: amount}(payable(address(rebateHook)));
assertEq(address(rebateHook).balance, amount, "Hook should have received ETH");
// Try to withdraw ETH using address(0) as token address
// This fails because the contract tries to call IERC20(address(0)).transfer
vm.expectRevert();
rebateHook.withdrawTokens(address(0), address(this), amount);
// Verify ETH is still stuck
assertEq(address(rebateHook).balance, amount, "ETH should still be in the hook");
}
}

Recommended Mitigation:
Add a method to withdraw ETH or generalize withdrawTokens to handle address(0) as ETH.

[L-5] ZERO_DELTA Returned With Fee Override Causes Future Incompatibility


Description

The hook returns:

return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG
);

ZERO_DELTA means:

"The hook wants zero token deltas—it does not take any tokens from the swap."

However, overriding fees without specifying deltas is an anti-pattern in Uniswap V4.

Why?

Because:

  • If the protocol ever wants to collect a portion of the fee

  • Or use afterSwap deltas

  • Or forward deltas to another contract

…the ZERO_DELTA return prevents it, unless the hook is redesigned.

This makes future upgrades and extension impossible without redeploying a new hook.


Impact

  • Protocol cannot collect premium fees even if intended

  • Extending hook functionality in future becomes impossible

  • Forces protocol migration

  • Eventual breaking changes if deltas are added incorrectly

If the protocol claims fee revenue, this becomes a Medium financial bug.


Recommended Mitigation

Prepare the hook for fee delta handling:

BeforeSwapDelta memory delta = BeforeSwapDeltaLibrary.create();
delta.setToken0Delta(...);
delta.setToken1Delta(...);
return (
selector,
delta,
fee | overrideFlag
);

Or at minimum:

  • Document explicitly that hook never collects tokens

  • Rename event fields accordingly

Updates

Lead Judging Commences

chaossr Lead Judge 11 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!