// SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.17; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {ILevelResolver} from "./interfaces/ILevelResolver.sol"; import {ICRS} from "./interfaces/ICRS.sol"; import {ICrsNft} from "./interfaces/ICrsNft.sol"; import {IRewarder} from "./interfaces/IRewarder.sol"; import {IVirtualDistributor} from "./interfaces/IVirtualDistributor.sol"; /** * @dev Distribute stacking rewards according to an NFT metadata, claimable at some later point. * It's possible to add `pendingRewards` to the related NFT metadata effectively attaching rewards * to NFT until they are claimed with `harvest` function. */ contract VirtualDistributor is IVirtualDistributor, ReentrancyGuard, Ownable, Pausable { using SafeCast for uint256; using SafeCast for int256; /// @notice Info of each NFT reward debt and virtual amount /// stored in a single pool /// `rewardDebt` tracks the reward per share when the user entered the pool /// and is used to determine how much rewards a user is entitled to /// `virtualAmount` The virtual amount deposited. Calculated like so (multiplier * amount) / scale_factor /// assumption here is that we will never go over 2^256 -1 /// on any user deposits /// `lockedUntil` tracks timestamp when rewards can be harvested by NFT owner /// 0 means locked rewards, which can be used for trading NFT with unclaimed rewards. struct NftInfo { int256 rewardDebt; uint256 virtualAmount; uint256 lockedUntil; } /// @notice Info of each pool. /// `crs` CRS registry. /// `project` CRS node related to experience token. /// `rewarder` Address of the rewarder delegate. /// `virtualTotalSupply` The total virtual supply in this pool. /// `accRewardPerShare` The amount of reward each share is entitled to. /// Users entering a pool have their reward debt start at current reward per share and /// then they get the delta between where the reward per share goes to and where they started. /// `lastRewardBlock` The last block where rewards were paid for this pool /// `allocPoint` The amount of allocation points assigned to the pool. /// Also known as the amount of Reward to distribute per block. /// this will allow an admin to unlock the pool if need be. /// This value defaults to false so that users have to respect the lockup period. struct PoolInfo { ICRS crs; bytes32 project; address rewarder; uint256 virtualTotalSupply; uint256 accRewardPerShare; uint128 lastRewardBlock; uint120 allocPoint; } /// @notice Info for a deposit. /// `amount` amount of tokens the user has provided. /// assumption here is that we will never go over 2^256 -1 /// on any NFT deposits /// `multiplier` is the multiplier NFT received on it's level. /// This is used to calculate virtual liquidity deltas. struct DepositInfo { uint256 amount; uint128 multiplier; } /// @notice Info of each pool rewards multipliers available. /// map NFT contract to a block lock time to a rewards multiplier mapping(address => uint128) public rewardMultipliers; /// @notice Info of each pool for NFT contract. mapping(address => PoolInfo) public poolInfo; uint256 public rewardPerBlock = 1e18; // variable has been made constant to cut down on gas costs uint256 constant private ACC_PRECISION = 1e23; // decimals for rewards multiplier uint256 constant private SCALE_FACTOR = 1e4; // how many blocks should pass between unlocking and harvest uint256 public unlockPeriod = 86400; // 24 hours /// @notice Info of each NFT reward debt and virtual amount. /// One object is instantiated per NFT per pool, info of each NFT participating in rewards program. mapping(address => mapping(uint256 => NftInfo)) public nftInfo; /// @dev Total allocation points. Must be the sum of all allocation points in all pools. uint256 public totalAllocPoint; event NewRewardPerBlock(uint256 indexed amount); event LogPoolAddition( address indexed nftContract, uint256 allocPoint, uint128 rewardMultiplier, address indexed rewarder ); event LogUpdatePool( address indexed nftContract, uint128 indexed lastRewardBlock, uint256 lpSupply, uint256 accRewardPerShare ); event LogPoolMultiplier(address indexed nftContract, uint256 indexed multiplier); event LogPoolAllocPoint(address indexed nftContract, uint120 allocPoint, address indexed rewarder, bool overwrite); event Dropin(address indexed user, address indexed nftContract, uint256 indexed tokenId, uint256 amount); event Harvest(address user, address indexed to, address indexed nftContract, uint256 indexed tokenId, uint256 amount); event UnlockPeriodChanged(uint256 newPeriod); /** * @dev Initialise virtual distributor. */ constructor(uint256 _rewardPerBlock) { rewardPerBlock = _rewardPerBlock; emit NewRewardPerBlock(_rewardPerBlock); emit UnlockPeriodChanged(unlockPeriod); } /** * @dev Owner can pause and continue distribution. * @param _state Pass true to pause, false to continue. */ function adminSetPaused(bool _state) external onlyOwner { if (_state) { _pause(); } else { _unpause(); } } /** * @dev Owner can change unlock delay, but not less than 1 hour. * @param _newPeriod Minimum seconds between unlock and harvest */ function adminChangeUnlockPeriod(uint256 _newPeriod) external onlyOwner { require(_newPeriod > 3600, "at least 1 hour"); unlockPeriod = _newPeriod; emit UnlockPeriodChanged(_newPeriod); } /** * @dev Allows owner to change the amount of reward per block * make sure to call the update pool function before hitting this function * this will ensure that all of the rewards a user earned previously get paid out * @param _newBlockReward The new amount of reward per block to distribute. */ function adminUpdateBlockReward(uint256 _newBlockReward) external onlyOwner { rewardPerBlock = _newBlockReward; emit NewRewardPerBlock(_newBlockReward); } /** * @dev Add a new pool. Can only be called by the owner. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _allocPoint New AP of the pool. * @param _rewardMultiplier Reward Multiplier. * @param _rewarder Address of the rewarder delegate. */ function adminAddPool( address _nftContract, uint120 _allocPoint, uint128 _rewardMultiplier, address _rewarder ) external onlyOwner { require(_allocPoint > 0, "pool must have allocation points to be created"); uint128 _lastRewardBlock = block.number.toUint128(); totalAllocPoint += _allocPoint; rewardMultipliers[_nftContract] = _rewardMultiplier; emit LogPoolMultiplier(_nftContract, _rewardMultiplier); poolInfo[_nftContract] = PoolInfo({ crs: ICrsNft(_nftContract).crs(), project: ICrsNft(_nftContract).baseNode(), rewarder: _rewarder, allocPoint: _allocPoint, virtualTotalSupply: 0, // virtual total supply starts at 0 as there is 0 initial supply lastRewardBlock: _lastRewardBlock, accRewardPerShare: 0 }); emit LogPoolAddition(_nftContract, _allocPoint, _rewardMultiplier, _rewarder); } /** * @dev Allows owner to change the pool multiplier. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _newRewardsMultiplier updated rewards multiplier. */ function adminChangePoolMultiplier( address _nftContract, uint64 _newRewardsMultiplier ) external onlyOwner { rewardMultipliers[_nftContract] = _newRewardsMultiplier; emit LogPoolMultiplier(_nftContract, _newRewardsMultiplier); } /** * @dev Update the given pool's reward allocation point. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _allocPoint New AP of the pool. * @param _rewarder Address of the rewarder delegate. * @param _overwrite True if _rewarder should be `set`. Otherwise `_rewarder` is ignored. */ function adminSetAllocPoint(address _nftContract, uint120 _allocPoint, address _rewarder, bool _overwrite) public onlyOwner { // we must update the pool before applying set so that all previously accrued rewards // are paid out before alloc points change updatePool(_nftContract); totalAllocPoint = (totalAllocPoint - poolInfo[_nftContract].allocPoint) + _allocPoint; require(totalAllocPoint > 0, "total allocation points cannot be 0"); poolInfo[_nftContract].allocPoint = _allocPoint; if (_overwrite) { poolInfo[_nftContract].rewarder = _rewarder; emit LogPoolAllocPoint(_nftContract, _allocPoint, _rewarder, true); } else { emit LogPoolAllocPoint(_nftContract, _allocPoint, poolInfo[_nftContract].rewarder, false); } } /** * @dev Update reward variables for NFT contract pool. * @param _nftContract The NFT contract of the pool. See `poolInfo`. */ function updatePool(address _nftContract) public { PoolInfo storage pool = poolInfo[_nftContract]; // dry runs are ok uint128 _lastRewardBlock = pool.lastRewardBlock; if (block.number > _lastRewardBlock) { uint256 _accRewardPerShare = pool.accRewardPerShare; uint256 _virtualSupply = pool.virtualTotalSupply; uint256 _totalAllocPoint = totalAllocPoint; if (_virtualSupply > 0 && _totalAllocPoint != 0) { uint256 _blocks = block.number - _lastRewardBlock; uint256 _reward = (_blocks * rewardPerBlock * pool.allocPoint) / _totalAllocPoint; _accRewardPerShare += ((_reward * ACC_PRECISION) / _virtualSupply); pool.accRewardPerShare = _accRewardPerShare; } pool.lastRewardBlock = block.number.toUint128(); emit LogUpdatePool(_nftContract, pool.lastRewardBlock, _virtualSupply, _accRewardPerShare); } } /** * @dev Update reward variables for all pools. Be careful of gas spending! * @param _pools Pool addresses of all to be updated. Make sure to update all active pools. */ function massUpdatePools(address[] calldata _pools) external { uint256 _len = _pools.length; for (uint256 _i = 0; _i < _len; ++_i) { updatePool(_pools[_i]); } } /** * @dev Enlist NFT into the reward program. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. */ function join(address _nftContract, uint256 _tokenId) public nonReentrant whenNotPaused { // we have to update the pool before we allow a user to deposit so that we can correctly calculate their reward debt // if we didn't do this, it would allow users to steal from us by calling deposits and gaining rewards they aren't entitled to updatePool(_nftContract); uint128 _multiplier = rewardMultipliers[_nftContract]; require(_multiplier >= SCALE_FACTOR, "invalid multiplier"); require(IERC721(_nftContract).ownerOf(_tokenId) == msg.sender, "not an NFT owner"); PoolInfo storage pool = poolInfo[_nftContract]; NftInfo storage nftPoolData = nftInfo[_nftContract][_tokenId]; // Effects uint256 _amount = _getAmountByNFTLevel(pool.crs, pool.project, _tokenId); uint256 _currentVirtualAmount = nftPoolData.virtualAmount; uint256 _currentCredit = pendingRewards(_nftContract, _tokenId); // virtual amount is calculated by taking the users total deposited balance and multiplying // it by the multiplier then adding it to the aggregated virtual amount uint256 _virtualAmountDelta = (_amount * _multiplier) / SCALE_FACTOR; nftPoolData.virtualAmount = _virtualAmountDelta; // update reward debt after virtual amount is set by multiplying virtual amount delta by reward per share // this tells us when the user deposited and allows us to calculate their rewards later nftPoolData.rewardDebt = ((_virtualAmountDelta * pool.accRewardPerShare) / ACC_PRECISION - _currentCredit).toInt256(); // pool virtual total supply needs to increase here pool.virtualTotalSupply = (pool.virtualTotalSupply - _currentVirtualAmount) + _virtualAmountDelta; // Interactions IRewarder _rewarder = IRewarder(pool.rewarder); if (address(_rewarder) != address(0)) { _rewarder.onReward(_nftContract, _tokenId, msg.sender, msg.sender, 0, nftPoolData.virtualAmount); } emit Dropin(msg.sender, _nftContract, _tokenId, _amount); } /** * @dev View function to see all pending rewards on frontend. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. * @return pending reward for a given NFT. */ function pendingRewards(address _nftContract, uint256 _tokenId) public view returns (uint256) { PoolInfo storage pool = poolInfo[_nftContract]; NftInfo storage nft = nftInfo[_nftContract][_tokenId]; uint256 _accRewardPerShare = pool.accRewardPerShare; uint256 _lastRewardBlock = pool.lastRewardBlock; uint256 _virtualTotalSupply = pool.virtualTotalSupply; if (block.number > _lastRewardBlock && _virtualTotalSupply != 0) { // this is the block delta uint256 _blocks = block.number - _lastRewardBlock; // this is the amount of reward this pool is entitled to for the last n blocks uint256 _reward = (_blocks * rewardPerBlock * pool.allocPoint) / totalAllocPoint; // this is the new reward per each pool share _accRewardPerShare += ((_reward * ACC_PRECISION) / _virtualTotalSupply); } // use the virtual amount to calculate the users share of the pool and their pending rewards return (((nft.virtualAmount * _accRewardPerShare) / ACC_PRECISION).toInt256() - nft.rewardDebt).toUint256(); } /** * @dev Allow claiming rewards from NFT after a safe unlock period. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. */ function unlockHarvest(address _nftContract, uint256 _tokenId) external { require(address(poolInfo[_nftContract].rewarder) != address(0), "harvest closed"); require(IERC721(_nftContract).ownerOf(_tokenId) == msg.sender, "only NFT owner can unlock"); nftInfo[_nftContract][_tokenId].lockedUntil = block.timestamp + unlockPeriod; } /** * @dev Lock rewards on NFT to trade it in a safe way. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. */ function lockHarvest(address _nftContract, uint256 _tokenId) external { require(IERC721(_nftContract).ownerOf(_tokenId) == msg.sender, "only NFT owner can lock"); nftInfo[_nftContract][_tokenId].lockedUntil = 0; } /** * @dev Checks if reward is locked so it can be traded in a safe way. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. * @return true if rewards are safely locked on NFT against front-running. */ function isRewardSafelyLocked(address _nftContract, uint256 _tokenId) public view returns (bool) { return nftInfo[_nftContract][_tokenId].lockedUntil == 0; } /** * @dev Claim rewards accumulated on NFT. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. */ function harvest(address _nftContract, uint256 _tokenId) public nonReentrant whenNotPaused { updatePool(_nftContract); _harvest(_nftContract, _tokenId); } /** * @dev Helper function to harvest NFT rewards. * @param _nftContract The NFT contract of the pool. See `poolInfo`. * @param _tokenId NFT token id. */ function _harvest(address _nftContract, uint256 _tokenId) internal { PoolInfo storage pool = poolInfo[_nftContract]; NftInfo storage nft = nftInfo[_nftContract][_tokenId]; require(address(pool.rewarder) != address(0), "harvest closed"); uint256 _lock = nft.lockedUntil; require(_lock > 0 && _lock < block.timestamp, "harvest locked"); // assumption here is that we will never go over 2^256 -1 int256 _accumulatedReward = ((nft.virtualAmount * pool.accRewardPerShare) / ACC_PRECISION).toInt256(); // this should never happen assert(_accumulatedReward >= 0 && _accumulatedReward - nft.rewardDebt >= 0); uint256 _pendingReward = (_accumulatedReward - nft.rewardDebt).toUint256(); // if pending tribe is ever negative, revert as this can cause an underflow when we turn this number to a uint assert(_pendingReward.toInt256() >= 0); // Effects nft.rewardDebt = _accumulatedReward; // Interactions address _to = IERC721(_nftContract).ownerOf(_tokenId); require(_to != address(0), "invalid NFT"); IRewarder(pool.rewarder).onReward(_nftContract, _tokenId, msg.sender, _to, _pendingReward, nft.virtualAmount); emit Harvest(msg.sender, _to, _nftContract, _tokenId, _pendingReward); } /** * @dev Get level for NFT. * @param _crs Address of CRS registry. * @param _project CRS node for related experience tokens. * @param _tokenId NFT token id. * @return NFT leveled weight. */ function _getAmountByNFTLevel(ICRS _crs, bytes32 _project, uint256 _tokenId) internal view returns (uint256) { bytes32 _node = keccak256(abi.encodePacked(_project, bytes32(_tokenId))); return ILevelResolver(_crs.resolver(_project)).level(_project, _node) ** 2; } }