// SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.17; import {IRewarder} from "./interfaces/IRewarder.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @dev Transfer ERC20 tokens according to the reward configuration with vesting and staking. */ contract VestingRewarder is IRewarder, ReentrancyGuard { using SafeERC20 for IERC20; address public immutable CONFIGURATOR; address public immutable ADMIN; IERC20 public REWARD_TOKEN; address public TRUSTED_AUTHORIZED_CALLER; uint256 public VESTING_STARTS; uint256 public VESTING_ENDS; mapping (address => uint256) public vestingLedger; mapping (address => uint256) public claimedVestedLedger; event Rewarded( address indexed nftContract, uint256 indexed tokenId, address indexed user, address recipient, uint256 amount, uint256 newLpAmount ); event RewardedNonVested( address indexed nftContract, uint256 indexed tokenId, address indexed user, address recipient, uint256 amount, uint256 newLpAmount ); event ClaimedVested( address indexed user, uint256 amount ); event NewLPAmount( address indexed user, uint256 amount ); error InvalidConfig(); error Unauthorized(); error InvalidAmount(); error NotVested(); constructor(address _admin) { CONFIGURATOR = msg.sender; ADMIN = _admin; } /** * @dev Withdraw all unclaimed tokens allocated to this address for migration or security reasons. * * @param _beneficiary Address to recieve unclaimed tokens. */ function adminWithdrawUnclaimed(address _beneficiary) external { if (ADMIN != msg.sender) revert Unauthorized(); uint256 _currentBalance = REWARD_TOKEN.balanceOf(address(this)); REWARD_TOKEN.safeTransfer(_beneficiary, _currentBalance); } /** * @dev Called only once during configuration phase. * * @param _rewardToken ERC20 token which used for rewarding users. * @param _authorizedCaller Smart contract address which would issue rewards through this contract. * @param _vestingStarts UNIX timestamp which is expected to be in a past, so that 10% of tokens would be already vested. * @param _vestingEnds UNIX timestamp when 100% of tokens are vested. */ function immutableConfig(IERC20 _rewardToken, address _authorizedCaller, uint256 _vestingStarts, uint256 _vestingEnds) external { if (CONFIGURATOR != msg.sender) revert Unauthorized(); if (TRUSTED_AUTHORIZED_CALLER != address(0) || address(REWARD_TOKEN) != address(0)) revert Unauthorized(); if (address(_rewardToken) == address(0) || _authorizedCaller == address(0)) revert InvalidConfig(); TRUSTED_AUTHORIZED_CALLER = _authorizedCaller; REWARD_TOKEN = _rewardToken; if (_vestingStarts >= _vestingEnds) revert InvalidConfig(); VESTING_STARTS = _vestingStarts; VESTING_ENDS = _vestingEnds; } /** * @dev Placeholder is used, because reward ledger is handled by TRUSTED_AUTHORIZED_CALLER. * * @param amount LP token amount. * @return tokens to be rewarded. * @return amounts to be rewarded. */ function pendingTokens(address, uint256, uint256 amount) external view returns (IERC20[] memory, uint256[] memory) { IERC20[] memory _tokens = new IERC20[](1); _tokens[0] = REWARD_TOKEN; uint256[] memory _amounts = new uint256[](1); _amounts[0] = amount; return (_tokens, _amounts); } /** * @dev Method where reward transfer should happen. Unvested amounts are stored as record for future claiming. * it's expected that re-entrancy is handled by TRUSTED_AUTHORIZED_CALLER. * * @param _nftContract The NFT contract of the pool. * @param _tokenId NFT token id. * @param _user Address which triggers reward distribution * @param _recipient reward beneficiary address. * @param _amount pending reward. * @param _newLpAmount total LP tokens which recieve reward. */ function onReward( address _nftContract, uint256 _tokenId, address _user, address _recipient, uint256 _amount, uint256 _newLpAmount ) external { if (msg.sender != TRUSTED_AUTHORIZED_CALLER) revert Unauthorized(); if (_amount > 0) { uint256 _vestingStarts = VESTING_STARTS; if (block.timestamp <= _vestingStarts) revert NotVested(); uint256 _vestingEnds = VESTING_ENDS; if (block.timestamp >= _vestingEnds) { REWARD_TOKEN.safeTransfer(_recipient, _amount); emit Rewarded( _nftContract, _tokenId, _user, _recipient, _amount, _newLpAmount ); } else { vestingLedger[_recipient] += _amount; emit RewardedNonVested( _nftContract, _tokenId, _user, _recipient, _amount, _newLpAmount ); } } emit NewLPAmount(_recipient, _newLpAmount); } /** * @dev How many vesting tokens are available for claim for the recipient. * * @param _recipient reward beneficiary address. * @return amount of vested tokens available for claim. */ function claimableAmount(address _recipient) public view returns (uint256) { uint256 _vestingStarts = VESTING_STARTS; if (block.timestamp <= _vestingStarts) return 0; uint256 _vesting = vestingLedger[_recipient]; if (_vesting == 0) return 0; uint256 _vestingEnds = VESTING_ENDS; uint256 _claimed = claimedVestedLedger[_recipient]; if (_claimed >= _vesting) return 0; if (block.timestamp >= _vestingEnds) return _vesting - _claimed; uint256 _vestedTokens = _vesting * (block.timestamp - _vestingStarts) / (VESTING_ENDS - _vestingStarts); if (_claimed >= _vestedTokens) return 0; return _vestedTokens - _claimed; } /** * @dev Claim vested tokens, only beneficiary can claim. * * @param _recipient reward beneficiary address. */ function claimVested(address _recipient) public nonReentrant { if (msg.sender != _recipient) revert Unauthorized(); uint256 _notClaimed = claimableAmount(_recipient); if (_notClaimed == 0) revert NotVested(); claimedVestedLedger[_recipient] += _notClaimed; REWARD_TOKEN.safeTransfer(_recipient, _notClaimed); emit ClaimedVested(_recipient, _notClaimed); } }