// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /** * @title FeeCollector * @notice Collects and distributes protocol fees * @dev Supports multiple tokens and multiple fee recipients */ contract FeeCollector is AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); /** * @notice Fee recipient information */ struct FeeRecipient { address recipient; uint256 shareBps; // Share in basis points (10000 = 100%) bool isActive; } mapping(address => FeeRecipient[]) private _feeRecipients; // token => recipients mapping(address => uint256) private _totalCollected; // token => total collected mapping(address => uint256) private _totalDistributed; // token => total distributed event FeesCollected( address indexed token, address indexed from, uint256 amount, uint256 timestamp ); event FeesDistributed( address indexed token, address indexed recipient, uint256 amount, uint256 timestamp ); event FeeRecipientAdded( address indexed token, address indexed recipient, uint256 shareBps, uint256 timestamp ); event FeeRecipientRemoved( address indexed token, address indexed recipient, uint256 timestamp ); /** * @notice Constructor * @param admin Address that will receive DEFAULT_ADMIN_ROLE */ constructor(address admin) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(FEE_MANAGER_ROLE, admin); } /** * @notice Collect fees in a token * @param token Token address (address(0) for native ETH) * @param amount Amount to collect * @dev Can be called by anyone, typically called by contracts that collect fees */ function collectFees(address token, uint256 amount) external payable nonReentrant { if (token == address(0)) { // Native ETH require(msg.value == amount, "FeeCollector: ETH amount mismatch"); } else { // ERC20 token require(msg.value == 0, "FeeCollector: no ETH expected"); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); } _totalCollected[token] += amount; emit FeesCollected(token, msg.sender, amount, block.timestamp); } /** * @notice Distribute collected fees to recipients * @param token Token address (address(0) for native ETH) * @dev Requires FEE_MANAGER_ROLE */ function distributeFees(address token) external onlyRole(FEE_MANAGER_ROLE) nonReentrant { FeeRecipient[] memory recipients = _feeRecipients[token]; require(recipients.length > 0, "FeeCollector: no recipients configured"); uint256 balance = token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this)); require(balance > 0, "FeeCollector: no fees to distribute"); uint256 totalDistributed = 0; for (uint256 i = 0; i < recipients.length; i++) { if (!recipients[i].isActive) continue; uint256 amount = (balance * recipients[i].shareBps) / 10000; if (token == address(0)) { // Native ETH (bool success, ) = recipients[i].recipient.call{value: amount}(""); require(success, "FeeCollector: ETH transfer failed"); } else { // ERC20 token IERC20(token).safeTransfer(recipients[i].recipient, amount); } totalDistributed += amount; _totalDistributed[token] += amount; emit FeesDistributed(token, recipients[i].recipient, amount, block.timestamp); } // Ensure we distributed exactly the balance (within rounding) require(totalDistributed <= balance, "FeeCollector: distribution overflow"); } /** * @notice Add a fee recipient for a token * @param token Token address (address(0) for native ETH) * @param recipient Recipient address * @param shareBps Share in basis points (10000 = 100%) * @dev Requires FEE_MANAGER_ROLE */ function addFeeRecipient( address token, address recipient, uint256 shareBps ) external onlyRole(FEE_MANAGER_ROLE) { require(recipient != address(0), "FeeCollector: zero recipient"); require(shareBps > 0 && shareBps <= 10000, "FeeCollector: invalid share"); // Check if recipient already exists FeeRecipient[] storage recipients = _feeRecipients[token]; for (uint256 i = 0; i < recipients.length; i++) { require(recipients[i].recipient != recipient, "FeeCollector: recipient already exists"); } recipients.push(FeeRecipient({ recipient: recipient, shareBps: shareBps, isActive: true })); emit FeeRecipientAdded(token, recipient, shareBps, block.timestamp); } /** * @notice Remove a fee recipient * @param token Token address * @param recipient Recipient address to remove * @dev Requires FEE_MANAGER_ROLE */ function removeFeeRecipient(address token, address recipient) external onlyRole(FEE_MANAGER_ROLE) { FeeRecipient[] storage recipients = _feeRecipients[token]; for (uint256 i = 0; i < recipients.length; i++) { if (recipients[i].recipient == recipient) { recipients[i] = recipients[recipients.length - 1]; recipients.pop(); emit FeeRecipientRemoved(token, recipient, block.timestamp); return; } } revert("FeeCollector: recipient not found"); } /** * @notice Get fee recipients for a token * @param token Token address * @return Array of fee recipients */ function getFeeRecipients(address token) external view returns (FeeRecipient[] memory) { return _feeRecipients[token]; } /** * @notice Get total collected fees for a token * @param token Token address * @return Total collected amount */ function getTotalCollected(address token) external view returns (uint256) { return _totalCollected[token]; } /** * @notice Get total distributed fees for a token * @param token Token address * @return Total distributed amount */ function getTotalDistributed(address token) external view returns (uint256) { return _totalDistributed[token]; } /** * @notice Get current balance for a token * @param token Token address (address(0) for native ETH) * @return Current balance */ function getBalance(address token) external view returns (uint256) { if (token == address(0)) { return address(this).balance; } else { return IERC20(token).balanceOf(address(this)); } } /** * @notice Emergency withdraw (admin only) * @param token Token address (address(0) for native ETH) * @param to Recipient address * @param amount Amount to withdraw * @dev Requires DEFAULT_ADMIN_ROLE */ function emergencyWithdraw( address token, address to, uint256 amount ) external onlyRole(DEFAULT_ADMIN_ROLE) nonReentrant { require(to != address(0), "FeeCollector: zero recipient"); if (token == address(0)) { (bool success, ) = to.call{value: amount}(""); require(success, "FeeCollector: ETH transfer failed"); } else { IERC20(token).safeTransfer(to, amount); } } }