[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.
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 {
vm.recordLogs();
rebateHook.ChangeFee(true, 100, true, 100);
Vm.Log[] memory entries = vm.getRecordedLogs();
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
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();
uint256 hugeAmount = 1_000_000_000 ether;
reFiToken.mint(user, hugeAmount);
assertEq(reFiToken.balanceOf(user), hugeAmount);
assertEq(reFiToken.totalSupply(), initialSupply + hugeAmount);
reFiToken.mint(user, hugeAmount);
assertEq(reFiToken.balanceOf(user), hugeAmount * 2);
}
}
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
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 {
uint256 amount = 1 ether;
new ForceSendETH{value: amount}(payable(address(rebateHook)));
assertEq(address(rebateHook).balance, amount, "Hook should have received ETH");
vm.expectRevert();
rebateHook.withdrawTokens(address(0), address(this), amount);
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:
…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: