Platform: Code4rena
Start Date: 23/02/2024
Pot Size: $92,000 USDC
Total HM: 0
Participants: 47
Period: 10 days
Judge: 0xTheC0der
Id: 336
League: ETH
Rank: 15/47
Findings: 1
Award: $694.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: CodeWasp
Also found by: 0xdice91, 0xlemon, Aamir, Al-Qa-qa, AlexCzm, BAHOZ, Bauchibred, Breeje, DadeKuma, Fassi_Security, PetarTolev, Shield, SpicyMeatball, Trust, ZanyBonzy, cheatc0d3, gesha17, haxatron, imare, jesjupyter, kutugu, lsaudit, marchev, merlinboii, nnez, osmanozdemir1, peanuts, radev_sw, twicek, visualbits
694.2987 USDC - $694.30
https://github.com/code-423n4/2024-02-uniswap-foundation/blob/5298812a129f942555466ebaa6ea9a2af4be0ccc/src/V3FactoryOwner.sol#L193-L195 https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/UniswapV3Pool.sol#L856-L865
The claimFees
function is tasked with claiming protocol fees by paying out PAYOUT_TOKEN
instead of fees collected by the pool. It includes validation to protect the caller from receiving less amount of tokens than what was requested.
if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) { revert V3FactoryOwner__InsufficientFeesCollected(); }
Due to the UniswapV3Pool collecting protocol fees, it transfers 1 wei less if the caller attempts to collect all protocol fees. At this point, this could potentially disrupt the claimFees
function in V3FactoryOwner.
if (amount0 > 0) { if (amount0 == protocolFees.token0) amount0--; // ensure that the slot is not cleared, for gas savings protocolFees.token0 -= amount0; TransferHelper.safeTransfer(token0, recipient, amount0); } if (amount1 > 0) { if (amount1 == protocolFees.token1) amount1--; // ensure that the slot is not cleared, for gas savings protocolFees.token1 -= amount1; TransferHelper.safeTransfer(token1, recipient, amount1); }
collectProtocol
function in UniswapV3Pool returns 1000e6 - 1 USDC and 1000e6 - 1 USDT, and the transaction is reverted because the returned amount is less than the requested amount.// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.23; import {Vm, Test, console} from "forge-std/Test.sol"; import {UniStaker} from "src/UniStaker.sol"; import {IUniswapV3FactoryOwnerActions} from "src/interfaces/IUniswapV3FactoryOwnerActions.sol"; import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; import {INotifiableRewardReceiver} from "src/interfaces/INotifiableRewardReceiver.sol"; import {IERC20Delegates} from "src/interfaces/IERC20Delegates.sol"; import {ERC20, IERC20} from "openzeppelin/token/ERC20/ERC20.sol"; import {V3FactoryOwner} from "src/V3FactoryOwner.sol"; import {TransferHelper} from "v3-core/libraries/TransferHelper.sol"; contract PausableToken is ERC20{ bool public isPaused = false; address public owner; constructor(address _owner, string memory _name, string memory _symbol) ERC20(_name, _symbol){ owner = _owner; } function transfer(address to, uint256 value) public override returns (bool) { require(!isPaused, "Contract is paused!"); return super.transfer(to, value); } function transferFrom(address from, address to, uint256 value) public override returns (bool){ require(!isPaused, "Contract is paused!"); return super.transferFrom(from, to, value); } function setIsPaused(bool _status) public { require(owner == msg.sender, "Only owner"); isPaused = _status; } function mint(address _account, uint256 _amount) public { _mint(_account, _amount); } } contract MockERC20 is ERC20{ constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol){} function mint(address _account, uint256 _amount) public { _mint(_account, _amount); } } contract UniswapV3PoolMock{ struct ProtocolFees { uint128 token0; uint128 token1; } ProtocolFees public protocolFees; address public token0; address public token1; event CollectProtocol(address indexed sender, address indexed recipient, uint128 amount0, uint128 amount1); constructor(address _token0, address _token1) { token0 = _token0; token1 = _token1; } function setProtocolFees(uint128 _amount0, uint128 _amount1) public { protocolFees = ProtocolFees({token0: _amount0, token1: _amount1}); } function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) public returns (uint128 amount0, uint128 amount1){ amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested; amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested; if (amount0 > 0) { if (amount0 == protocolFees.token0) amount0--; // ensure that the slot is not cleared, for gas savings protocolFees.token0 -= amount0; TransferHelper.safeTransfer(token0, recipient, amount0); } if (amount1 > 0) { if (amount1 == protocolFees.token1) amount1--; // ensure that the slot is not cleared, for gas savings protocolFees.token1 -= amount1; TransferHelper.safeTransfer(token1, recipient, amount1); } emit CollectProtocol(msg.sender, recipient, amount0, amount1); } } contract UniStakerPOC is Test { // Addresses address public admin = makeAddr("ADMIN"); address public pausableTokenOwner = makeAddr("PAUSABLE_OWNER"); address public uniV3Factory = makeAddr("UniswapV3Factory"); // Values uint256 public payoutAmount = 1e18; PausableToken pausableToken; MockERC20 weth; MockERC20 uni; UniStaker uniStaker; V3FactoryOwner factoryOwner; UniswapV3PoolMock uniV3Pool; function setUp() public { // Deploy contracts pausableToken = new PausableToken(pausableTokenOwner, "Pausable Token", "PT"); weth = new MockERC20("WETH token", "WETH"); uni = new MockERC20("Uniswap Gov token", "UNI"); uniStaker = new UniStaker(IERC20(address(weth)), IERC20Delegates(address(uni)), address(admin)); factoryOwner = new V3FactoryOwner(admin, IUniswapV3FactoryOwnerActions(address(uniStaker)), IERC20(weth), payoutAmount, INotifiableRewardReceiver(address(uniStaker))); // Deploy a pool uniV3Pool = new UniswapV3PoolMock(address(pausableToken), address(weth)); // Allow the V3FactoryOwner contract to call the `setRewardNotifier` function. vm.prank(admin); uniStaker.setRewardNotifier(address(factoryOwner), true); } function test_claimFeesRevertIfRequestedMaximumAmount() public { // Protocol fees: 100 PT + 1 WETH pausableToken.mint(address(uniV3Pool), 100e18); weth.mint(address(uniV3Pool), 1e18); uniV3Pool.setProtocolFees(100e18, 1e18); (uint128 token0Fees, uint128 token1Fees) = uniV3Pool.protocolFees(); // Attempting to claim all 100 PT and 1 WETH. address caller = makeAddr("CALLER"); weth.mint(address(caller), 1e18); vm.startPrank(caller); weth.approve(address(factoryOwner), payoutAmount); // The transaction was reverted with `V3FactoryOwner__InsufficientFeesCollected()` factoryOwner.claimFees(IUniswapV3PoolOwnerActions(address(uniV3Pool)), caller, token0Fees, token1Fees); vm.stopPrank(); } }
foundry.toml:
[profile.default] optimizer = true optimizer_runs = 10_000_000 remappings = [ "openzeppelin/=lib/openzeppelin-contracts/contracts", "v3-periphery=node_modules/@uniswap/v3-periphery/contracts", "v3-core=node_modules/@uniswap/v3-core/contracts" ] solc_version = "0.8.23"
forge test --mt test_claimFeesRevertIfRequestedMaximumAmount
Manual Review Foundry
- if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) { - revert V3FactoryOwner__InsufficientFeesCollected(); - } + if (_amount0 < _amount0Requested - 1 || _amount1 < _amount1Requested - 1) { + revert V3FactoryOwner__InsufficientFeesCollected(); + }
DoS
#0 - c4-judge
2024-03-07T12:38:24Z
MarioPoneder marked the issue as duplicate of #34
#1 - c4-judge
2024-03-14T01:37:30Z
MarioPoneder marked the issue as satisfactory
#2 - c4-judge
2024-03-26T22:59:07Z
MarioPoneder marked the issue as grade-b