// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import {ConfirmedOwner} from "../../shared/access/ConfirmedOwner.sol"; import {ITypeAndVersion} from "../../shared/interfaces/ITypeAndVersion.sol"; import {Common} from "../libraries/Common.sol"; import {IDestinationRewardManager} from "./interfaces/IDestinationRewardManager.sol"; import {IERC20} from "@openzeppelin/contracts@4.8.3/interfaces/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts@4.8.3/token/ERC20/utils/SafeERC20.sol"; /** * @title DestinationRewardManager * @author Michael Fletcher * @author Austin Born * @notice This contract will be used to reward any configured recipients within a pool. Recipients will receive a share * of their pool relative to their configured weight. */ contract DestinationRewardManager is IDestinationRewardManager, ConfirmedOwner, ITypeAndVersion { using SafeERC20 for IERC20; // @dev The mapping of total fees collected for a particular pot: s_totalRewardRecipientFees[poolId] mapping(bytes32 => uint256) public s_totalRewardRecipientFees; // @dev The mapping of fee balances for each pot last time the recipient claimed: // s_totalRewardRecipientFeesLastClaimedAmounts[poolId][recipient] mapping(bytes32 => mapping(address => uint256)) public s_totalRewardRecipientFeesLastClaimedAmounts; // @dev The mapping of RewardRecipient weights for a particular poolId: // s_rewardRecipientWeights[poolId][rewardRecipient]. mapping(bytes32 => mapping(address => uint256)) public s_rewardRecipientWeights; // @dev Keep track of the reward recipient weights that have been set to prevent duplicates mapping(bytes32 => bool) public s_rewardRecipientWeightsSet; // @dev Store a list of pool ids that have been registered, to make off chain lookups easier bytes32[] public s_registeredPoolIds; // @dev The address for the LINK contract address public immutable i_linkAddress; // The total weight of all RewardRecipients. 1e18 = 100% of the pool fees uint64 private constant PERCENTAGE_SCALAR = 1e18; // The fee manager address mapping(address => address) public s_feeManagerAddressList; // @notice Thrown whenever the RewardRecipient weights are invalid error InvalidWeights(); // @notice Thrown when any given address is invalid error InvalidAddress(); // @notice Thrown when the pool id is invalid error InvalidPoolId(); // @notice Thrown when the calling contract is not within the authorized contracts error Unauthorized(); // @notice Thrown when getAvailableRewardPoolIds parameters are incorrectly set error InvalidPoolLength(); // Events emitted upon state change event RewardRecipientsUpdated(bytes32 indexed poolId, Common.AddressAndWeight[] newRewardRecipients); event RewardsClaimed(bytes32 indexed poolId, address indexed recipient, uint192 quantity); event FeeManagerUpdated(address newFeeManagerAddress); event FeePaid(FeePayment[] payments, address payer); /** * @notice Constructor * @param linkAddress address of the wrapped LINK token */ constructor( address linkAddress ) ConfirmedOwner(msg.sender) { //ensure that the address ia not zero if (linkAddress == address(0)) revert InvalidAddress(); i_linkAddress = linkAddress; } // @inheritdoc ITypeAndVersion function typeAndVersion() external pure override returns (string memory) { return "DestinationRewardManager 0.4.0"; } // @inheritdoc IERC165 function supportsInterface( bytes4 interfaceId ) external pure override returns (bool) { return interfaceId == type(IDestinationRewardManager).interfaceId; } modifier onlyOwnerOrFeeManager() { if (msg.sender != s_feeManagerAddressList[msg.sender] && msg.sender != owner()) revert Unauthorized(); _; } modifier onlyOwnerOrRecipientInPool( bytes32 poolId ) { if (s_rewardRecipientWeights[poolId][msg.sender] == 0 && msg.sender != owner()) revert Unauthorized(); _; } modifier onlyFeeManager() { if (msg.sender != s_feeManagerAddressList[msg.sender]) revert Unauthorized(); _; } /// @inheritdoc IDestinationRewardManager function onFeePaid(FeePayment[] calldata payments, address payer) external override onlyFeeManager { uint256 totalFeeAmount; for (uint256 i; i < payments.length; ++i) { unchecked { //the total amount for any ERC-20 asset cannot exceed 2^256 - 1 //see // https://github.com/OpenZeppelin/openzeppelin-contracts/blob/36bf1e46fa811f0f07d38eb9cfbc69a955f300ce/contracts/token/ERC20/ERC20.sol#L266 //for example implementation. s_totalRewardRecipientFees[payments[i].poolId] += payments[i].amount; //tally the total payable fees totalFeeAmount += payments[i].amount; } } //transfer the fees to this contract IERC20(i_linkAddress).safeTransferFrom(payer, address(this), totalFeeAmount); emit FeePaid(payments, payer); } /// @inheritdoc IDestinationRewardManager function claimRewards( bytes32[] memory poolIds ) external override { _claimRewards(msg.sender, poolIds); } // wrapper impl for claimRewards function _claimRewards(address recipient, bytes32[] memory poolIds) internal returns (uint256) { //get the total amount claimable for this recipient uint256 claimAmount; //loop and claim all the rewards in the poolId pot for (uint256 i; i < poolIds.length; ++i) { //get the poolId to be claimed bytes32 poolId = poolIds[i]; //get the total fees for the pot uint256 totalFeesInPot = s_totalRewardRecipientFees[poolId]; unchecked { //avoid unnecessary storage reads if there's no fees in the pot if (totalFeesInPot == 0) continue; //get the claimable amount for this recipient, this calculation will never exceed the amount in the pot uint256 claimableAmount = totalFeesInPot - s_totalRewardRecipientFeesLastClaimedAmounts[poolId][recipient]; //calculate the recipients share of the fees, which is their weighted share of the difference between the last // amount they claimed and the current amount in the pot. This can never be more than the total amount in // existence uint256 recipientShare = (claimableAmount * s_rewardRecipientWeights[poolId][recipient]) / PERCENTAGE_SCALAR; //if there's no fees to claim, continue as there's nothing to update if (recipientShare == 0) continue; //keep track of the total amount claimable, this can never be more than the total amount in existence claimAmount += recipientShare; //set the current total amount of fees in the pot as it's used to calculate future claims s_totalRewardRecipientFeesLastClaimedAmounts[poolId][recipient] = totalFeesInPot; //emit event if the recipient has rewards to claim emit RewardsClaimed(poolIds[i], recipient, uint192(recipientShare)); } } //check if there's any rewards to claim in the given poolId if (claimAmount != 0) { //transfer the reward to the recipient IERC20(i_linkAddress).safeTransfer(recipient, claimAmount); } return claimAmount; } /// @inheritdoc IDestinationRewardManager function setRewardRecipients( bytes32 poolId, Common.AddressAndWeight[] calldata rewardRecipientAndWeights ) external override onlyOwnerOrFeeManager { //revert if there are no recipients to set if (rewardRecipientAndWeights.length == 0) revert InvalidAddress(); //check that the weights have not been previously set if (s_rewardRecipientWeightsSet[poolId]) revert InvalidPoolId(); //keep track of the registered poolIds to make off chain lookups easier s_registeredPoolIds.push(poolId); //keep track of which pools have had their reward recipients set s_rewardRecipientWeightsSet[poolId] = true; //set the reward recipients, this will only be called once and contain the full set of RewardRecipients with a total // weight of 100% _setRewardRecipientWeights(poolId, rewardRecipientAndWeights, PERCENTAGE_SCALAR); emit RewardRecipientsUpdated(poolId, rewardRecipientAndWeights); } function _setRewardRecipientWeights( bytes32 poolId, Common.AddressAndWeight[] calldata rewardRecipientAndWeights, uint256 expectedWeight ) internal { //we can't update the weights if it contains duplicates if (Common._hasDuplicateAddresses(rewardRecipientAndWeights)) revert InvalidAddress(); //loop all the reward recipients and validate the weight and address uint256 totalWeight; for (uint256 i; i < rewardRecipientAndWeights.length; ++i) { //get the weight uint256 recipientWeight = rewardRecipientAndWeights[i].weight; //get the address address recipientAddress = rewardRecipientAndWeights[i].addr; //ensure the reward recipient address is not zero if (recipientAddress == address(0)) revert InvalidAddress(); //save/overwrite the weight for the reward recipient s_rewardRecipientWeights[poolId][recipientAddress] = recipientWeight; unchecked { //keep track of the cumulative weight, this cannot overflow as the total weight is restricted at 1e18 totalWeight += recipientWeight; } } //if total weight is not met, the fees will either be under or over distributed if (totalWeight != expectedWeight) revert InvalidWeights(); } /// @inheritdoc IDestinationRewardManager function updateRewardRecipients( bytes32 poolId, Common.AddressAndWeight[] calldata newRewardRecipients ) external override onlyOwner { //create an array of poolIds to pass to _claimRewards if required bytes32[] memory poolIds = new bytes32[](1); poolIds[0] = poolId; //loop all the reward recipients and claim their rewards before updating their weights uint256 existingTotalWeight; for (uint256 i; i < newRewardRecipients.length; ++i) { //get the address address recipientAddress = newRewardRecipients[i].addr; //get the existing weight uint256 existingWeight = s_rewardRecipientWeights[poolId][recipientAddress]; //if a recipient is updated, the rewards must be claimed first as they can't claim previous fees at the new weight _claimRewards(newRewardRecipients[i].addr, poolIds); unchecked { //keep tally of the weights so that the expected collective weight is known existingTotalWeight += existingWeight; } } //update the reward recipients, if the new collective weight isn't equal to the previous collective weight, the fees // will either be under or over distributed _setRewardRecipientWeights(poolId, newRewardRecipients, existingTotalWeight); //emit event emit RewardRecipientsUpdated(poolId, newRewardRecipients); } /// @inheritdoc IDestinationRewardManager function payRecipients(bytes32 poolId, address[] calldata recipients) external onlyOwnerOrRecipientInPool(poolId) { //convert poolIds to an array to match the interface of _claimRewards bytes32[] memory poolIdsArray = new bytes32[](1); poolIdsArray[0] = poolId; //loop each recipient and claim the rewards for each of the pools and assets for (uint256 i; i < recipients.length; ++i) { _claimRewards(recipients[i], poolIdsArray); } } /// @inheritdoc IDestinationRewardManager function addFeeManager( address newFeeManagerAddress ) external onlyOwner { if (newFeeManagerAddress == address(0)) revert InvalidAddress(); if (s_feeManagerAddressList[newFeeManagerAddress] != address(0)) revert InvalidAddress(); s_feeManagerAddressList[newFeeManagerAddress] = newFeeManagerAddress; emit FeeManagerUpdated(newFeeManagerAddress); } /// @inheritdoc IDestinationRewardManager function removeFeeManager( address feeManagerAddress ) external onlyOwner { if (s_feeManagerAddressList[feeManagerAddress] == address(0)) revert InvalidAddress(); delete s_feeManagerAddressList[feeManagerAddress]; } /// @inheritdoc IDestinationRewardManager function getAvailableRewardPoolIds( address recipient, uint256 startIndex, uint256 endIndex ) external view returns (bytes32[] memory) { //get the length of the pool ids which we will loop through and potentially return uint256 registeredPoolIdsLength = s_registeredPoolIds.length; uint256 lastIndex = endIndex > registeredPoolIdsLength ? registeredPoolIdsLength : endIndex; if (startIndex > lastIndex) revert InvalidPoolLength(); //create a new array with the maximum amount of potential pool ids bytes32[] memory claimablePoolIds = new bytes32[](lastIndex - startIndex); //we want the pools which a recipient has funds for to be sequential, so we need to keep track of the index uint256 poolIdArrayIndex; //loop all the pool ids, and check if the recipient has a registered weight and a claimable amount for (uint256 i = startIndex; i < lastIndex; ++i) { //get the poolId bytes32 poolId = s_registeredPoolIds[i]; //if the recipient has a weight, they are a recipient of this poolId if (s_rewardRecipientWeights[poolId][recipient] != 0) { //get the total in this pool uint256 totalPoolAmount = s_totalRewardRecipientFees[poolId]; //if the recipient has any LINK, then add the poolId to the array unchecked { //s_totalRewardRecipientFeesLastClaimedAmounts can never exceed total pool amount, and the number of pools // can't exceed the max array length if (totalPoolAmount - s_totalRewardRecipientFeesLastClaimedAmounts[poolId][recipient] != 0) { claimablePoolIds[poolIdArrayIndex++] = poolId; } } } } return claimablePoolIds; } }