Contract Name:
QuestBoardRings
Contract Source Code:
//SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.20;
import "../libraries/QuestDataTypes.sol";
/** @title Interface fo Quest Board V2 */
/// @author Paladin
interface IQuestBoard {
// Structs
/** @notice Struct holding the parameters of the Quest common for all periods */
struct Quest {
// Address of the Quest creator (caller of createQuest() method)
address creator;
// Address of the ERC20 used for rewards
address rewardToken;
// Address of the target Gauge
address gauge;
// Total number of periods for the Quest
uint48 duration;
// Timestamp where the 1st QuestPeriod starts
uint48 periodStart;
// Total amount of rewards paid for this Quest
// If changes were made to the parameters of this Quest, this will account
// any added reward amounts
uint256 totalRewardAmount;
// Total reward amount that can be distributed for each period
uint256 rewardAmountPerPeriod;
// Min Amount of reward for each vote (for 1 veToken)
uint256 minRewardPerVote;
// Max Amount of reward for each vote (for 1 veToken)
uint256 maxRewardPerVote;
// Min Target Bias for the Gauge
uint256 minObjectiveVotes;
// Max Target Bias for the Gauge
uint256 maxObjectiveVotes;
// Quest Types
QuestTypes types;
}
/** @notice Struct with all the Quest types */
struct QuestTypes {
QuestDataTypes.QuestVoteType voteType;
QuestDataTypes.QuestRewardsType rewardsType;
QuestDataTypes.QuestCloseType closeType;
}
/** @notice Struct for the local variables in _createQuest() method */
struct CreateVars {
address creator;
uint256 rewardPerPeriod;
uint256 minObjective;
uint256 startPeriod;
uint256 periodIterator;
uint256 voterLength;
}
/** @notice Struct for the local variables in extendQuest() method */
struct ExtendVars {
uint256 lastPeriod;
address gauge;
address rewardToken;
uint256 rewardPerPeriod;
uint256 periodIterator;
uint256 minObjective;
uint256 maxObjective;
uint256 minRewardPerVote;
uint256 maxRewardPerVote;
}
/** @notice Struct for the local variables in updateQuestParameters() methods */
struct UpdateVars {
uint256 remainingDuration;
uint256 currentPeriod;
uint256 newRewardPerPeriod;
uint256 newMaxObjective;
uint256 newMinObjective;
address creator;
}
// Events
/** @notice Event emitted when the Board is Initialized */
event Init(address distributor);
/** @notice Event emitted when a new Quest is created */
event NewQuest(
uint256 indexed questID,
address indexed creator,
address indexed gauge,
address rewardToken,
uint48 duration,
uint256 startPeriod
);
/** @notice Event emitted when the Quest duration is extended */
event ExtendQuestDuration(uint256 indexed questID, uint256 addedDuration, uint256 addedRewardAmount);
/** @notice Event emitted when a Quest parameters are updated */
event UpdateQuestParameters(
uint256 indexed questID,
uint256 indexed updatePeriod,
uint256 newMinRewardPerVote,
uint256 newMaxRewardPerVote,
uint256 addedPeriodRewardAmount
);
/** @notice Event emitted when Quest creator withdraw undistributed rewards */
event WithdrawUnusedRewards(uint256 indexed questID, address recipient, uint256 amount);
/** @notice Event emitted when a Quest Voter list is updated from it's original version */
event VoterListUpdated(uint256 indexed questID);
/** @notice Event emitted when a Period is Closed */
event PeriodClosed(uint256 indexed questID, uint256 indexed period);
/** @notice Event emitted when a Quest Period rools over the undistributed rewards */
event RewardsRollover(uint256 indexed questID, uint256 newRewardPeriod, uint256 newMinRewardPerVote, uint256 newMaxRewardPerVote);
/** @notice Event emitted when a Period Bias is fixed */
event PeriodBiasFixed(uint256 indexed questID, uint256 indexed period, uint256 newBias);
/** @notice Event emitted when a new reward token is whitelisted */
event WhitelistToken(address indexed token, uint256 minRewardPerVote);
/** @notice Event emitted when a reward token parameter is updated */
event UpdateRewardToken(address indexed token, uint256 newMinRewardPerVote);
/** @notice Event emitted when the contract is killed */
event Killed(uint256 killTime);
/** @notice Event emitted when the contract is unkilled */
event Unkilled(uint256 unkillTime);
/** @notice Event emitted when the Quest creator withdraw all unused funds (if the contract was killed) */
event EmergencyWithdraw(uint256 indexed questID, address recipient, uint256 amount);
/** @notice Event emitted when a new manager is approved */
event ApprovedManager(address indexed manager);
/** @notice Event emitted when a manager is removed */
event RemovedManager(address indexed manager);
/** @notice Event emitted when the Chest address is updated */
event ChestUpdated(address oldChest, address newChest);
/** @notice Event emitted when a custom fee ratio is set for a given address */
event SetCustomFeeRatio(address indexed creator, uint256 customFeeRatio);
/** @notice Event emitted when the Distributor address is updated */
event DistributorUpdated(address oldDistributor, address newDistributor);
/** @notice Event emitted when the fee ratio is updated */
event PlatformFeeRatioUpdated(uint256 oldFeeRatio, uint256 newFeeRatio);
/** @notice Event emitted when the minimum objective of votes is updated */
event MinObjectiveUpdated(uint256 oldMinObjective, uint256 newMinObjective);
}
pragma solidity 0.8.20;
//SPDX-License-Identifier: MIT
interface IRingsVoter {
function isGauge(address gauge) external view returns (bool);
function isAlive(address gauge) external view returns (bool);
function getTotalVotes() external view returns (uint256);
function getGaugeVotes(address gauge) external view returns (uint256);
function getNftVotesOnGauge(uint256 tokenId, address gauge) external view returns (uint256);
function getTotalVotesAtPeriod(uint256 ts) external view returns (uint256);
function getGaugeVotesAtPeriod(address gauge, uint256 ts) external view returns (uint256);
function getNftVotesOnGaugeAtPeriod(uint256 tokenId, address gauge, uint256 ts) external view returns (uint256);
function vote(uint256 tokenId, address[] calldata gaugeList, uint256[] calldata weights) external;
function reset(uint256 tokenId) external;
function recast(uint256 tokenId) external;
function voteMultiple(uint256[] calldata tokenIds, address[] calldata gaugeList, uint256[] calldata weights) external;
function resetMultiple(uint256[] calldata tokenIds) external;
function recastMultiple(uint256[] calldata tokenIds) external;
function depositBudget(uint256 amount) external;
function addGauge(address gauge, string memory label) external returns (uint256 index);
}
pragma solidity ^0.8.16;
//SPDX-License-Identifier: MIT
library Errors {
// Common Errors
error AddressZero();
error NullAmount();
error CallerNotAllowed();
error IncorrectRewardToken();
error SameAddress();
error InequalArraySizes();
error EmptyArray();
error EmptyParameters();
error AlreadyInitialized();
error InvalidParameter();
error CannotRecoverToken();
error ForbiddenCall();
error CannotBeOwner();
error CallerNotPendingOwner();
error Killed();
error AlreadyKilled();
error NotKilled();
error KillDelayExpired();
error KillDelayNotExpired();
// Merkle Errors
error MerkleRootNotUpdated();
error AlreadyClaimed();
error InvalidProof();
error EmptyMerkleRoot();
error IncorrectRewardAmount();
error MerkleRootFrozen();
error NotFrozen();
error AlreadyFrozen();
// Quest Errors
error CallerNotQuestBoard();
error IncorrectQuestID();
error IncorrectPeriod();
error TokenNotWhitelisted();
error QuestAlreadyListed();
error QuestNotListed();
error PeriodAlreadyUpdated();
error PeriodNotClosed();
error PeriodStillActive();
error PeriodNotListed();
error EmptyQuest();
error EmptyPeriod();
error ExpiredQuest();
error QuestNotStarted();
error QuestTypeForbidden();
error NotInitialized();
error NoDistributorSet();
error DisitributorFail();
error InvalidGauge();
error InvalidQuestID();
error InvalidPeriod();
error ObjectiveTooLow();
error NewObjectiveTooLow();
error RewardPerVoteTooLow();
error MinValueOverMaxValue();
error IncorrectDuration();
error IncorrectAddDuration();
error IncorrectTotalRewardAmount();
error IncorrectAddedRewardAmount();
error IncorrectFeeAmount();
error InvalidQuestType();
error QuestTypesIncompatible();
error CalletNotQuestCreator();
error LowerRewardPerVote();
error LowerObjective();
error CreatorNotAllowed();
error AlreadyListed();
error NotListed();
error MaxListSize();
error BoardIsNotAllowedDistributor();
// Proxy
error InvalidChainID();
error InvalidCaller();
error PeriodBiasNotUpdated();
error GaugeAlreadyAdded();
error AmountExceedsBalance();
error RecoverFailed();
//Math
error NumberExceed48Bits();
}
//SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.16;
/** @title Data Types fo Quest Board V2 */
/// @author Paladin
library QuestDataTypes {
// Enums
/** @notice State of each Period for each Quest */
enum PeriodState { ZERO, ACTIVE, CLOSED, DISTRIBUTED }
// All Periods are ACTIVE at creation since the voters from past periods are also accounted for the future period
/** @notice Types of Vote logic for Quests */
enum QuestVoteType { NORMAL, BLACKLIST, WHITELIST }
// NORMAL: basic vote logic
// BLACKLIST: remove the blacklisted voters bias from the gauge biases
// WHITELIST: only sum up the whitelisted voters biases
/** @notice Types of Rewards logic for Quests */
enum QuestRewardsType { FIXED, RANGE }
// FIXED: reward per vote is fixed
// RANGE: reward per vote is a range between min and max, based on the Quest completion between min objective and max objective
/** @notice Types of logic for undistributed rewards when closing Quest periods */
enum QuestCloseType { NORMAL, ROLLOVER, DISTRIBUTE }
// NORMAL: undistributed rewards are avaialble to be withdrawn by the creator
// ROLLOVER: undistributed rewards are added to the next period, increasing the reward/vote parameter
// DISTRIBUTE: undistributed rewards are sent to the gauge for direct distribution
}
//██████╗ █████╗ ██╗ █████╗ ██████╗ ██╗███╗ ██╗
//██╔══██╗██╔══██╗██║ ██╔══██╗██╔══██╗██║████╗ ██║
//██████╔╝███████║██║ ███████║██║ ██║██║██╔██╗ ██║
//██╔═══╝ ██╔══██║██║ ██╔══██║██║ ██║██║██║╚██╗██║
//██║ ██║ ██║███████╗██║ ██║██████╔╝██║██║ ╚████║
//╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═══╝
//SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.20;
import "./oz/interfaces/IERC20.sol";
import "./oz/libraries/SafeERC20.sol";
import "./oz/utils/MerkleProof.sol";
import "./utils/Owner.sol";
import "./oz/utils/ReentrancyGuard.sol";
import "./libraries/Errors.sol";
/** @title Quest Multi Merkle Distributor */
/// @author Paladin
/*
Contract holds ERC20 rewards from Quests
Can handle multiple MerkleRoots
*/
contract MultiMerkleDistributor is Owner, ReentrancyGuard {
using SafeERC20 for IERC20;
/** @notice Seconds in a Week */
uint256 private constant WEEK = 604800;
/** @notice Mapping listing the reward token associated to each Quest ID */
// QuestID => reward token
mapping(uint256 => address) public questRewardToken;
/** @notice Mapping of tokens this contract is or was distributing */
// token address => boolean
mapping(address => bool) public rewardTokens;
// Periods: timestamp => start of a week, used as a voting period
// in the Curve GaugeController though the timestamp / WEEK * WEEK logic.
// Handled through the QuestManager contract.
// Those can be fetched through this contract when they are closed, or through the QuestManager contract.
/** @notice List of Closed QuestPeriods by Quest ID */
// QuestID => array of periods
mapping(uint256 => uint256[]) public questClosedPeriods;
/** @notice Merkle Root for each period of a Quest (indexed by Quest ID) */
// QuestID => period => merkleRoot
mapping(uint256 => mapping(uint256 => bytes32)) public questMerkleRootPerPeriod;
/** @notice Amount of rewards for each period of a Quest (indexed by Quest ID) */
// QuestID => period => totalRewardsAmount
mapping(uint256 => mapping(uint256 => uint256)) public questRewardsPerPeriod;
/** @notice BitMap of claims for each period of a Quest */
// QuestID => period => claimedBitMap
// This is a packed array of booleans.
mapping(uint256 => mapping(uint256 => mapping(uint256 => uint256))) private questPeriodClaimedBitMap;
/** @notice Address of the QuestBoard contract */
address public immutable questBoard;
// Events
/** @notice Event emitted when a user Claims */
event Claimed(
uint256 indexed questID,
uint256 indexed period,
uint256 index,
uint256 amount,
address rewardToken,
address indexed account
);
/** @notice Event emitted when a New Quest is added */
event NewQuest(uint256 indexed questID, address rewardToken);
/** @notice Event emitted when a Period of a Quest is updated (when the Merkle Root is added) */
event QuestPeriodUpdated(uint256 indexed questID, uint256 indexed period, bytes32 merkleRoot);
// Modifier
/** @notice Check the caller is either the admin or the QuestBoard contract */
modifier onlyAllowed(){
if(msg.sender != questBoard && msg.sender != owner()) revert Errors.CallerNotAllowed();
_;
}
// Constructor
constructor(address _questBoard){
if(_questBoard == address(0)) revert Errors.AddressZero();
questBoard = _questBoard;
}
// Functions
/**
* @notice Checks if the rewards were claimed for a user on a given period
* @dev Checks if the rewards were claimed for a user (based on the index) on a given period
* @param questID ID of the Quest
* @param period Amount of underlying to borrow
* @param index Index of the claim
* @return bool : true if already claimed
*/
function isClaimed(uint256 questID, uint256 period, uint256 index) public view returns (bool) {
uint256 claimedWordIndex = index >> 8;
uint256 claimedBitIndex = index & 0xff;
uint256 claimedWord = questPeriodClaimedBitMap[questID][period][claimedWordIndex];
uint256 mask = (1 << claimedBitIndex);
return claimedWord & mask != 0;
}
/**
* @dev Sets the rewards as claimed for the index on the given period
* @param questID ID of the Quest
* @param period Timestamp of the period
* @param index Index of the claim
*/
function _setClaimed(uint256 questID, uint256 period, uint256 index) private {
uint256 claimedWordIndex = index >> 8;
uint256 claimedBitIndex = index & 0xff;
questPeriodClaimedBitMap[questID][period][claimedWordIndex] |= (1 << claimedBitIndex);
}
//Basic Claim
/**
* @notice Claims the reward for a user for a given period of a Quest
* @dev Claims the reward for a user for a given period of a Quest if the correct proof was given
* @param questID ID of the Quest
* @param period Timestamp of the period
* @param index Index in the Merkle Tree
* @param account Address of the user claiming the rewards
* @param amount Amount of rewards to claim
* @param merkleProof Proof to claim the rewards
*/
function claim(uint256 questID, uint256 period, uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof) public nonReentrant {
if(account == address(0)) revert Errors.AddressZero();
if(questMerkleRootPerPeriod[questID][period] == 0) revert Errors.MerkleRootNotUpdated();
if(isClaimed(questID, period, index)) revert Errors.AlreadyClaimed();
// Check that the given parameters match the given Proof
bytes32 node = keccak256(abi.encodePacked(questID, period, index, account, amount));
if(!MerkleProof.verify(merkleProof, questMerkleRootPerPeriod[questID][period], node)) revert Errors.InvalidProof();
// Set the rewards as claimed for that period
// And transfer the rewards to the user
address rewardToken = questRewardToken[questID];
_setClaimed(questID, period, index);
questRewardsPerPeriod[questID][period] -= amount;
IERC20(rewardToken).safeTransfer(account, amount);
emit Claimed(questID, period, index, amount, rewardToken, account);
}
//Struct ClaimParams
struct ClaimParams {
uint256 questID;
uint256 period;
uint256 index;
uint256 amount;
bytes32[] merkleProof;
}
//Multi Claim
/**
* @notice Claims multiple rewards for a given list
* @dev Calls the claim() method for each entry in the claims array
* @param account Address of the user claiming the rewards
* @param claims List of ClaimParams struct data to claim
*/
function multiClaim(address account, ClaimParams[] calldata claims) external {
uint256 length = claims.length;
if(length == 0) revert Errors.EmptyParameters();
for(uint256 i; i < length;){
claim(claims[i].questID, claims[i].period, claims[i].index, account, claims[i].amount, claims[i].merkleProof);
unchecked{ ++i; }
}
}
//FullQuest Claim (form of Multi Claim but for only one Quest => only one ERC20 transfer)
//Only works for the given periods (in ClaimParams) for the Quest. Any omitted period will be skipped
/**
* @notice Claims the reward for all the given periods of a Quest, and transfer all the rewards at once
* @dev Sums up all the rewards for given periods of a Quest, and executes only one transfer
* @param account Address of the user claiming the rewards
* @param questID ID of the Quest
* @param claims List of ClaimParams struct data to claim
*/
function claimQuest(address account, uint256 questID, ClaimParams[] calldata claims) external nonReentrant {
if(account == address(0)) revert Errors.AddressZero();
uint256 length = claims.length;
if(length == 0) revert Errors.EmptyParameters();
// Total amount claimable, to transfer at once
uint256 totalClaimAmount;
address rewardToken = questRewardToken[questID];
for(uint256 i; i < length;){
if(claims[i].questID != questID) revert Errors.IncorrectQuestID();
if(questMerkleRootPerPeriod[questID][claims[i].period] == 0) revert Errors.MerkleRootNotUpdated();
if(isClaimed(questID, claims[i].period, claims[i].index)) revert Errors.AlreadyClaimed();
// For each period given, if the proof matches the given parameters,
// set as claimed and add to the to total to transfer
bytes32 node = keccak256(abi.encodePacked(questID, claims[i].period, claims[i].index, account, claims[i].amount));
if(!MerkleProof.verify(claims[i].merkleProof, questMerkleRootPerPeriod[questID][claims[i].period], node)) revert Errors.InvalidProof();
_setClaimed(questID, claims[i].period, claims[i].index);
questRewardsPerPeriod[questID][claims[i].period] -= claims[i].amount;
totalClaimAmount += claims[i].amount;
emit Claimed(questID, claims[i].period, claims[i].index, claims[i].amount, rewardToken, account);
unchecked{ ++i; }
}
// Transfer the total claimed amount
IERC20(rewardToken).safeTransfer(account, totalClaimAmount);
}
/**
* @notice Returns all current Closed periods for the given Quest ID
* @dev Returns all current Closed periods for the given Quest ID
* @param questID ID of the Quest
* @return uint256[] : List of closed periods
*/
function getClosedPeriodsByQuests(uint256 questID) external view returns (uint256[] memory) {
return questClosedPeriods[questID];
}
// Manager functions
/**
* @notice Adds a new Quest to the listing
* @dev Adds a new Quest ID and the associated reward token
* @param questID ID of the Quest
* @param token Address of the ERC20 reward token
* @return bool : success
*/
function addQuest(uint256 questID, address token) external returns(bool) {
if(msg.sender != questBoard) revert Errors.CallerNotAllowed();
if(questRewardToken[questID] != address(0)) revert Errors.QuestAlreadyListed();
if(token == address(0)) revert Errors.TokenNotWhitelisted();
// Add a new Quest using the QuestID, and list the reward token for that Quest
questRewardToken[questID] = token;
if(!rewardTokens[token]) rewardTokens[token] = true;
emit NewQuest(questID, token);
return true;
}
/**
* @notice Adds a new period & the rewards of this period for a Quest
* @dev Adds a new period & the rewards of this period for a Quest
* @param questID ID of the Quest
* @param period Timestamp of the period
* @param totalRewardAmount Total amount of rewards to distribute for the period
* @return bool : success
*/
function addQuestPeriod(uint256 questID, uint256 period, uint256 totalRewardAmount) external returns(bool) {
period = (period / WEEK) * WEEK;
if(msg.sender != questBoard) revert Errors.CallerNotAllowed();
if(questRewardToken[questID] == address(0)) revert Errors.QuestNotListed();
if(questRewardsPerPeriod[questID][period] != 0) revert Errors.PeriodAlreadyUpdated();
if(period == 0) revert Errors.IncorrectPeriod();
if(totalRewardAmount == 0) revert Errors.NullAmount();
questRewardsPerPeriod[questID][period] = totalRewardAmount;
return true;
}
function fixQuestPeriod(uint256 questID, uint256 period, uint256 newTotalRewardAmount) external returns(bool) {
if(msg.sender != questBoard) revert Errors.CallerNotAllowed();
period = (period / WEEK) * WEEK;
if(questRewardToken[questID] == address(0)) revert Errors.QuestNotListed();
if(period == 0) revert Errors.IncorrectPeriod();
if(questRewardsPerPeriod[questID][period] == 0) revert Errors.PeriodNotListed();
uint256 previousTotalRewardAmount = questRewardsPerPeriod[questID][period];
questRewardsPerPeriod[questID][period] = newTotalRewardAmount;
if(previousTotalRewardAmount > newTotalRewardAmount){
// Send back the extra amount of reward token that was incorrectly sent
// In the case of missing reward token, the Board will send them to this contract
uint256 extraAmount = previousTotalRewardAmount - newTotalRewardAmount;
IERC20(questRewardToken[questID]).safeTransfer(questBoard, extraAmount);
}
return true;
}
/**
* @notice Updates the period of a Quest by adding the Merkle Root
* @dev Add the Merkle Root for the eriod of the given Quest
* @param questID ID of the Quest
* @param period timestamp of the period
* @param totalAmount sum of all rewards for the Merkle Tree
* @param merkleRoot MerkleRoot to add
* @return bool: success
*/
function updateQuestPeriod(uint256 questID, uint256 period, uint256 totalAmount, bytes32 merkleRoot) external onlyAllowed returns(bool) {
period = (period / WEEK) * WEEK;
if(questRewardToken[questID] == address(0)) revert Errors.QuestNotListed();
if(period == 0) revert Errors.IncorrectPeriod();
if(questRewardsPerPeriod[questID][period] == 0) revert Errors.PeriodNotListed();
if(questMerkleRootPerPeriod[questID][period] != 0) revert Errors.PeriodAlreadyUpdated();
if(merkleRoot == 0) revert Errors.EmptyMerkleRoot();
// Add a new Closed Period for the Quest
questClosedPeriods[questID].push(period);
if(totalAmount != questRewardsPerPeriod[questID][period]) revert Errors.IncorrectRewardAmount();
// Add the new MerkleRoot for that Closed Period
questMerkleRootPerPeriod[questID][period] = merkleRoot;
emit QuestPeriodUpdated(questID, period, merkleRoot);
return true;
}
// Admin functions
/**
* @notice Recovers ERC2O tokens sent by mistake to the contract
* @dev Recovers ERC2O tokens sent by mistake to the contract
* @param token Address tof the EC2O token
* @return bool: success
*/
function recoverERC20(address token) external onlyOwner nonReentrant returns(bool) {
if(rewardTokens[token]) revert Errors.CannotRecoverToken();
uint256 amount = IERC20(token).balanceOf(address(this));
if(amount == 0) revert Errors.NullAmount();
IERC20(token).safeTransfer(owner(), amount);
return true;
}
//
/**
* @notice Allows to update the MerkleRoot for a given period of a Quest if the current Root is incorrect
* @dev Updates the MerkleRoot for the period of the Quest
* @param questID ID of the Quest
* @param period Timestamp of the period
* @param merkleRoot New MerkleRoot to add
* @return bool : success
*/
function emergencyUpdateQuestPeriod(uint256 questID, uint256 period, uint256 addedRewardAmount, bytes32 merkleRoot) external onlyOwner returns(bool) {
// In case the given MerkleRoot was incorrect:
// Process:
// 1 - block claims for the Quest period by using this method to set an incorrect MerkleRoot, where no proof matches the root
// 2 - prepare a new Merkle Tree, taking in account user previous claims on that period, and missing/overpaid rewards
// a - for all new claims to be added, set them after the last index of the previous Merkle Tree
// b - for users that did not claim, keep the same index, and adjust the amount to claim if needed
// c - for indexes that were claimed, place an empty node in the Merkle Tree (with an amount at 0 & the address 0xdead as the account)
// 3 - update the Quest period with the correct MerkleRoot
// (no need to change the Bitmap, as the new MerkleTree will account for the indexes already claimed)
period = (period / WEEK) * WEEK;
if(questRewardToken[questID] == address(0)) revert Errors.QuestNotListed();
if(period == 0) revert Errors.IncorrectPeriod();
if(questMerkleRootPerPeriod[questID][period] == 0) revert Errors.PeriodNotClosed();
if(merkleRoot == 0) revert Errors.EmptyMerkleRoot();
questMerkleRootPerPeriod[questID][period] = merkleRoot;
questRewardsPerPeriod[questID][period] += addedRewardAmount;
emit QuestPeriodUpdated(questID, period, merkleRoot);
return true;
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (token/ERC20/extensions/IERC20Permit.sol)
pragma solidity ^0.8.0;
/**
* @dev Interface of the ERC20 Permit extension allowing approvals to be made via signatures, as defined in
* https://eips.ethereum.org/EIPS/eip-2612[EIP-2612].
*
* Adds the {permit} method, which can be used to change an account's ERC20 allowance (see {IERC20-allowance}) by
* presenting a message signed by the account. By not relying on {IERC20-approve}, the token holder account doesn't
* need to send a transaction, and thus is not required to hold Ether at all.
*/
interface IERC20Permit {
/**
* @dev Sets `value` as the allowance of `spender` over ``owner``'s tokens,
* given ``owner``'s signed approval.
*
* IMPORTANT: The same issues {IERC20-approve} has related to transaction
* ordering also apply here.
*
* Emits an {Approval} event.
*
* Requirements:
*
* - `spender` cannot be the zero address.
* - `deadline` must be a timestamp in the future.
* - `v`, `r` and `s` must be a valid `secp256k1` signature from `owner`
* over the EIP712-formatted function arguments.
* - the signature must use ``owner``'s current nonce (see {nonces}).
*
* For more information on the signature format, see the
* https://eips.ethereum.org/EIPS/eip-2612#specification[relevant EIP
* section].
*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
/**
* @dev Returns the current nonce for `owner`. This value must be
* included whenever a signature is generated for {permit}.
*
* Every successful call to {permit} increases ``owner``'s nonce by one. This
* prevents a signature from being used multiple times.
*/
function nonces(address owner) external view returns (uint256);
/**
* @dev Returns the domain separator used in the encoding of the signature for {permit}, as defined by {EIP712}.
*/
// solhint-disable-next-line func-name-mixedcase
function DOMAIN_SEPARATOR() external view returns (bytes32);
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.6.0) (token/ERC20/IERC20.sol)
pragma solidity ^0.8.0;
/**
* @dev Interface of the ERC20 standard as defined in the EIP.
*/
interface IERC20 {
/**
* @dev Emitted when `value` tokens are moved from one account (`from`) to
* another (`to`).
*
* Note that `value` may be zero.
*/
event Transfer(address indexed from, address indexed to, uint256 value);
/**
* @dev Emitted when the allowance of a `spender` for an `owner` is set by
* a call to {approve}. `value` is the new allowance.
*/
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Returns the amount of tokens in existence.
*/
function totalSupply() external view returns (uint256);
/**
* @dev Returns the amount of tokens owned by `account`.
*/
function balanceOf(address account) external view returns (uint256);
/**
* @dev Moves `amount` tokens from the caller's account to `to`.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* Emits a {Transfer} event.
*/
function transfer(address to, uint256 amount) external returns (bool);
/**
* @dev Returns the remaining number of tokens that `spender` will be
* allowed to spend on behalf of `owner` through {transferFrom}. This is
* zero by default.
*
* This value changes when {approve} or {transferFrom} are called.
*/
function allowance(address owner, address spender) external view returns (uint256);
/**
* @dev Sets `amount` as the allowance of `spender` over the caller's tokens.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* IMPORTANT: Beware that changing an allowance with this method brings the risk
* that someone may use both the old and the new allowance by unfortunate
* transaction ordering. One possible solution to mitigate this race
* condition is to first reduce the spender's allowance to 0 and set the
* desired value afterwards:
* https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
*
* Emits an {Approval} event.
*/
function approve(address spender, uint256 amount) external returns (bool);
/**
* @dev Moves `amount` tokens from `from` to `to` using the
* allowance mechanism. `amount` is then deducted from the caller's
* allowance.
*
* Returns a boolean value indicating whether the operation succeeded.
*
* Emits a {Transfer} event.
*/
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.8.0) (token/ERC20/utils/SafeERC20.sol)
pragma solidity ^0.8.0;
import "../interfaces/IERC20.sol";
import "../extensions/IERC20Permit.sol";
import "../utils/Address.sol";
/**
* @title SafeERC20
* @dev Wrappers around ERC20 operations that throw on failure (when the token
* contract returns false). Tokens that return no value (and instead revert or
* throw on failure) are also supported, non-reverting calls are assumed to be
* successful.
* To use this library you can add a `using SafeERC20 for IERC20;` statement to your contract,
* which allows you to call the safe operations as `token.safeTransfer(...)`, etc.
*/
library SafeERC20 {
using Address for address;
function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
}
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
}
/**
* @dev Deprecated. This function has issues similar to the ones found in
* {IERC20-approve}, and its usage is discouraged.
*
* Whenever possible, use {safeIncreaseAllowance} and
* {safeDecreaseAllowance} instead.
*/
function safeApprove(IERC20 token, address spender, uint256 value) internal {
// safeApprove should only be called when setting an initial allowance,
// or when resetting it to zero. To increase and decrease it, use
// 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
require(
(value == 0) || (token.allowance(address(this), spender) == 0),
"SafeERC20: approve from non-zero to non-zero allowance"
);
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
}
function safeIncreaseAllowance(IERC20 token, address spender, uint256 value) internal {
uint256 newAllowance = token.allowance(address(this), spender) + value;
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance));
}
function safeDecreaseAllowance(IERC20 token, address spender, uint256 value) internal {
unchecked {
uint256 oldAllowance = token.allowance(address(this), spender);
require(oldAllowance >= value, "SafeERC20: decreased allowance below zero");
uint256 newAllowance = oldAllowance - value;
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, newAllowance));
}
}
function safePermit(
IERC20Permit token,
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) internal {
uint256 nonceBefore = token.nonces(owner);
token.permit(owner, spender, value, deadline, v, r, s);
uint256 nonceAfter = token.nonces(owner);
require(nonceAfter == nonceBefore + 1, "SafeERC20: permit did not succeed");
}
/**
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
* on the return value: the return value is optional (but if data is returned, it must not be false).
* @param token The token targeted by the call.
* @param data The call data (encoded using abi.encode or one of its variants).
*/
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
// we're implementing it ourselves. We use {Address-functionCall} to perform this call, which verifies that
// the target address contains contract code and also asserts for success in the low-level call.
bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed");
if (returndata.length > 0) {
// Return data is optional
require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
}
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.8.0) (utils/Address.sol)
pragma solidity ^0.8.1;
/**
* @dev Collection of functions related to the address type
*/
library Address {
/**
* @dev Returns true if `account` is a contract.
*
* [IMPORTANT]
* ====
* It is unsafe to assume that an address for which this function returns
* false is an externally-owned account (EOA) and not a contract.
*
* Among others, `isContract` will return false for the following
* types of addresses:
*
* - an externally-owned account
* - a contract in construction
* - an address where a contract will be created
* - an address where a contract lived, but was destroyed
*
* Furthermore, `isContract` will also return true if the target contract within
* the same transaction is already scheduled for destruction by `SELFDESTRUCT`,
* which only has an effect at the end of a transaction.
* ====
*
* [IMPORTANT]
* ====
* You shouldn't rely on `isContract` to protect against flash loan attacks!
*
* Preventing calls from contracts is highly discouraged. It breaks composability, breaks support for smart wallets
* like Gnosis Safe, and does not provide security since it can be circumvented by calling from a contract
* constructor.
* ====
*/
function isContract(address account) internal view returns (bool) {
// This method relies on extcodesize/address.code.length, which returns 0
// for contracts in construction, since the code is only stored at the end
// of the constructor execution.
return account.code.length > 0;
}
/**
* @dev Replacement for Solidity's `transfer`: sends `amount` wei to
* `recipient`, forwarding all available gas and reverting on errors.
*
* https://eips.ethereum.org/EIPS/eip-1884[EIP1884] increases the gas cost
* of certain opcodes, possibly making contracts go over the 2300 gas limit
* imposed by `transfer`, making them unable to receive funds via
* `transfer`. {sendValue} removes this limitation.
*
* https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/[Learn more].
*
* IMPORTANT: because control is transferred to `recipient`, care must be
* taken to not create reentrancy vulnerabilities. Consider using
* {ReentrancyGuard} or the
* https://solidity.readthedocs.io/en/v0.5.11/security-considerations.html#use-the-checks-effects-interactions-pattern[checks-effects-interactions pattern].
*/
function sendValue(address payable recipient, uint256 amount) internal {
require(address(this).balance >= amount, "Address: insufficient balance");
(bool success, ) = recipient.call{value: amount}("");
require(success, "Address: unable to send value, recipient may have reverted");
}
/**
* @dev Performs a Solidity function call using a low level `call`. A
* plain `call` is an unsafe replacement for a function call: use this
* function instead.
*
* If `target` reverts with a revert reason, it is bubbled up by this
* function (like regular Solidity function calls).
*
* Returns the raw returned data. To convert to the expected return value,
* use https://solidity.readthedocs.io/en/latest/units-and-global-variables.html?highlight=abi.decode#abi-encoding-and-decoding-functions[`abi.decode`].
*
* Requirements:
*
* - `target` must be a contract.
* - calling `target` with `data` must not revert.
*
* _Available since v3.1._
*/
function functionCall(address target, bytes memory data) internal returns (bytes memory) {
return functionCallWithValue(target, data, 0, "Address: low-level call failed");
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`], but with
* `errorMessage` as a fallback revert reason when `target` reverts.
*
* _Available since v3.1._
*/
function functionCall(
address target,
bytes memory data,
string memory errorMessage
) internal returns (bytes memory) {
return functionCallWithValue(target, data, 0, errorMessage);
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],
* but also transferring `value` wei to `target`.
*
* Requirements:
*
* - the calling contract must have an ETH balance of at least `value`.
* - the called Solidity function must be `payable`.
*
* _Available since v3.1._
*/
function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) {
return functionCallWithValue(target, data, value, "Address: low-level call with value failed");
}
/**
* @dev Same as {xref-Address-functionCallWithValue-address-bytes-uint256-}[`functionCallWithValue`], but
* with `errorMessage` as a fallback revert reason when `target` reverts.
*
* _Available since v3.1._
*/
function functionCallWithValue(
address target,
bytes memory data,
uint256 value,
string memory errorMessage
) internal returns (bytes memory) {
require(address(this).balance >= value, "Address: insufficient balance for call");
(bool success, bytes memory returndata) = target.call{value: value}(data);
return verifyCallResultFromTarget(target, success, returndata, errorMessage);
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],
* but performing a static call.
*
* _Available since v3.3._
*/
function functionStaticCall(address target, bytes memory data) internal view returns (bytes memory) {
return functionStaticCall(target, data, "Address: low-level static call failed");
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`],
* but performing a static call.
*
* _Available since v3.3._
*/
function functionStaticCall(
address target,
bytes memory data,
string memory errorMessage
) internal view returns (bytes memory) {
(bool success, bytes memory returndata) = target.staticcall(data);
return verifyCallResultFromTarget(target, success, returndata, errorMessage);
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-}[`functionCall`],
* but performing a delegate call.
*
* _Available since v3.4._
*/
function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {
return functionDelegateCall(target, data, "Address: low-level delegate call failed");
}
/**
* @dev Same as {xref-Address-functionCall-address-bytes-string-}[`functionCall`],
* but performing a delegate call.
*
* _Available since v3.4._
*/
function functionDelegateCall(
address target,
bytes memory data,
string memory errorMessage
) internal returns (bytes memory) {
(bool success, bytes memory returndata) = target.delegatecall(data);
return verifyCallResultFromTarget(target, success, returndata, errorMessage);
}
/**
* @dev Tool to verify that a low level call to smart-contract was successful, and revert (either by bubbling
* the revert reason or using the provided one) in case of unsuccessful call or if target was not a contract.
*
* _Available since v4.8._
*/
function verifyCallResultFromTarget(
address target,
bool success,
bytes memory returndata,
string memory errorMessage
) internal view returns (bytes memory) {
if (success) {
if (returndata.length == 0) {
// only check isContract if the call was successful and the return data is empty
// otherwise we already know that it was a contract
require(isContract(target), "Address: call to non-contract");
}
return returndata;
} else {
_revert(returndata, errorMessage);
}
}
/**
* @dev Tool to verify that a low level call was successful, and revert if it wasn't, either by bubbling the
* revert reason or using the provided one.
*
* _Available since v4.3._
*/
function verifyCallResult(
bool success,
bytes memory returndata,
string memory errorMessage
) internal pure returns (bytes memory) {
if (success) {
return returndata;
} else {
_revert(returndata, errorMessage);
}
}
function _revert(bytes memory returndata, string memory errorMessage) private pure {
// Look for revert reason and bubble it up if present
if (returndata.length > 0) {
// The easiest way to bubble the revert reason is using memory via assembly
/// @solidity memory-safe-assembly
assembly {
let returndata_size := mload(returndata)
revert(add(32, returndata), returndata_size)
}
} else {
revert(errorMessage);
}
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts v4.4.1 (utils/Context.sol)
pragma solidity ^0.8.0;
/**
* @dev Provides information about the current execution context, including the
* sender of the transaction and its data. While these are generally available
* via msg.sender and msg.data, they should not be accessed in such a direct
* manner, since when dealing with meta-transactions the account sending and
* paying for execution may not be the actual sender (as far as an application
* is concerned).
*
* This contract is only required for intermediate, library-like contracts.
*/
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.8.0) (utils/cryptography/MerkleProof.sol)
pragma solidity ^0.8.0;
/**
* @dev These functions deal with verification of Merkle Tree proofs.
*
* The tree and the proofs can be generated using our
* https://github.com/OpenZeppelin/merkle-tree[JavaScript library].
* You will find a quickstart guide in the readme.
*
* WARNING: You should avoid using leaf values that are 64 bytes long prior to
* hashing, or use a hash function other than keccak256 for hashing leaves.
* This is because the concatenation of a sorted pair of internal nodes in
* the merkle tree could be reinterpreted as a leaf value.
* OpenZeppelin's JavaScript library generates merkle trees that are safe
* against this attack out of the box.
*/
library MerkleProof {
/**
* @dev Returns true if a `leaf` can be proved to be a part of a Merkle tree
* defined by `root`. For this, a `proof` must be provided, containing
* sibling hashes on the branch from the leaf to the root of the tree. Each
* pair of leaves and each pair of pre-images are assumed to be sorted.
*/
function verify(bytes32[] memory proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
return processProof(proof, leaf) == root;
}
/**
* @dev Calldata version of {verify}
*
* _Available since v4.7._
*/
function verifyCalldata(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
return processProofCalldata(proof, leaf) == root;
}
/**
* @dev Returns the rebuilt hash obtained by traversing a Merkle tree up
* from `leaf` using `proof`. A `proof` is valid if and only if the rebuilt
* hash matches the root of the tree. When processing the proof, the pairs
* of leafs & pre-images are assumed to be sorted.
*
* _Available since v4.4._
*/
function processProof(bytes32[] memory proof, bytes32 leaf) internal pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computedHash = _hashPair(computedHash, proof[i]);
}
return computedHash;
}
/**
* @dev Calldata version of {processProof}
*
* _Available since v4.7._
*/
function processProofCalldata(bytes32[] calldata proof, bytes32 leaf) internal pure returns (bytes32) {
bytes32 computedHash = leaf;
for (uint256 i = 0; i < proof.length; i++) {
computedHash = _hashPair(computedHash, proof[i]);
}
return computedHash;
}
/**
* @dev Returns true if the `leaves` can be simultaneously proven to be a part of a merkle tree defined by
* `root`, according to `proof` and `proofFlags` as described in {processMultiProof}.
*
* CAUTION: Not all merkle trees admit multiproofs. See {processMultiProof} for details.
*
* _Available since v4.7._
*/
function multiProofVerify(
bytes32[] memory proof,
bool[] memory proofFlags,
bytes32 root,
bytes32[] memory leaves
) internal pure returns (bool) {
return processMultiProof(proof, proofFlags, leaves) == root;
}
/**
* @dev Calldata version of {multiProofVerify}
*
* CAUTION: Not all merkle trees admit multiproofs. See {processMultiProof} for details.
*
* _Available since v4.7._
*/
function multiProofVerifyCalldata(
bytes32[] calldata proof,
bool[] calldata proofFlags,
bytes32 root,
bytes32[] memory leaves
) internal pure returns (bool) {
return processMultiProofCalldata(proof, proofFlags, leaves) == root;
}
/**
* @dev Returns the root of a tree reconstructed from `leaves` and sibling nodes in `proof`. The reconstruction
* proceeds by incrementally reconstructing all inner nodes by combining a leaf/inner node with either another
* leaf/inner node or a proof sibling node, depending on whether each `proofFlags` item is true or false
* respectively.
*
* CAUTION: Not all merkle trees admit multiproofs. To use multiproofs, it is sufficient to ensure that: 1) the tree
* is complete (but not necessarily perfect), 2) the leaves to be proven are in the opposite order they are in the
* tree (i.e., as seen from right to left starting at the deepest layer and continuing at the next layer).
*
* _Available since v4.7._
*/
function processMultiProof(
bytes32[] memory proof,
bool[] memory proofFlags,
bytes32[] memory leaves
) internal pure returns (bytes32 merkleRoot) {
// This function rebuilds the root hash by traversing the tree up from the leaves. The root is rebuilt by
// consuming and producing values on a queue. The queue starts with the `leaves` array, then goes onto the
// `hashes` array. At the end of the process, the last hash in the `hashes` array should contain the root of
// the merkle tree.
uint256 leavesLen = leaves.length;
uint256 totalHashes = proofFlags.length;
// Check proof validity.
require(leavesLen + proof.length - 1 == totalHashes, "MerkleProof: invalid multiproof");
// The xxxPos values are "pointers" to the next value to consume in each array. All accesses are done using
// `xxx[xxxPos++]`, which return the current value and increment the pointer, thus mimicking a queue's "pop".
bytes32[] memory hashes = new bytes32[](totalHashes);
uint256 leafPos = 0;
uint256 hashPos = 0;
uint256 proofPos = 0;
// At each step, we compute the next hash using two values:
// - a value from the "main queue". If not all leaves have been consumed, we get the next leaf, otherwise we
// get the next hash.
// - depending on the flag, either another value from the "main queue" (merging branches) or an element from the
// `proof` array.
for (uint256 i = 0; i < totalHashes; i++) {
bytes32 a = leafPos < leavesLen ? leaves[leafPos++] : hashes[hashPos++];
bytes32 b = proofFlags[i]
? (leafPos < leavesLen ? leaves[leafPos++] : hashes[hashPos++])
: proof[proofPos++];
hashes[i] = _hashPair(a, b);
}
if (totalHashes > 0) {
unchecked {
return hashes[totalHashes - 1];
}
} else if (leavesLen > 0) {
return leaves[0];
} else {
return proof[0];
}
}
/**
* @dev Calldata version of {processMultiProof}.
*
* CAUTION: Not all merkle trees admit multiproofs. See {processMultiProof} for details.
*
* _Available since v4.7._
*/
function processMultiProofCalldata(
bytes32[] calldata proof,
bool[] calldata proofFlags,
bytes32[] memory leaves
) internal pure returns (bytes32 merkleRoot) {
// This function rebuilds the root hash by traversing the tree up from the leaves. The root is rebuilt by
// consuming and producing values on a queue. The queue starts with the `leaves` array, then goes onto the
// `hashes` array. At the end of the process, the last hash in the `hashes` array should contain the root of
// the merkle tree.
uint256 leavesLen = leaves.length;
uint256 totalHashes = proofFlags.length;
// Check proof validity.
require(leavesLen + proof.length - 1 == totalHashes, "MerkleProof: invalid multiproof");
// The xxxPos values are "pointers" to the next value to consume in each array. All accesses are done using
// `xxx[xxxPos++]`, which return the current value and increment the pointer, thus mimicking a queue's "pop".
bytes32[] memory hashes = new bytes32[](totalHashes);
uint256 leafPos = 0;
uint256 hashPos = 0;
uint256 proofPos = 0;
// At each step, we compute the next hash using two values:
// - a value from the "main queue". If not all leaves have been consumed, we get the next leaf, otherwise we
// get the next hash.
// - depending on the flag, either another value from the "main queue" (merging branches) or an element from the
// `proof` array.
for (uint256 i = 0; i < totalHashes; i++) {
bytes32 a = leafPos < leavesLen ? leaves[leafPos++] : hashes[hashPos++];
bytes32 b = proofFlags[i]
? (leafPos < leavesLen ? leaves[leafPos++] : hashes[hashPos++])
: proof[proofPos++];
hashes[i] = _hashPair(a, b);
}
if (totalHashes > 0) {
unchecked {
return hashes[totalHashes - 1];
}
} else if (leavesLen > 0) {
return leaves[0];
} else {
return proof[0];
}
}
function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) {
return a < b ? _efficientHash(a, b) : _efficientHash(b, a);
}
function _efficientHash(bytes32 a, bytes32 b) private pure returns (bytes32 value) {
/// @solidity memory-safe-assembly
assembly {
mstore(0x00, a)
mstore(0x20, b)
value := keccak256(0x00, 0x40)
}
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.7.0) (access/Ownable.sol)
pragma solidity ^0.8.0;
import "./Context.sol";
/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* By default, the owner account will be the one that deploys the contract. This
* can later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available the modifier
* `onlyOwner`, which can be applied to your functions to restrict their use to
* the owner.
*/
abstract contract Ownable is Context {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev Initializes the contract setting the deployer as the initial owner.
*/
constructor() {
_transferOwnership(_msgSender());
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
_checkOwner();
_;
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view virtual returns (address) {
return _owner;
}
/**
* @dev Throws if the sender is not the owner.
*/
function _checkOwner() internal view virtual {
require(owner() == _msgSender(), "Ownable: caller is not the owner");
}
/**
* @dev Leaves the contract without owner. It will not be possible to call
* `onlyOwner` functions anymore. Can only be called by the current owner.
*
* NOTE: Renouncing ownership will leave the contract without an owner,
* thereby removing any functionality that is only available to the owner.
*/
function renounceOwnership() public virtual onlyOwner {
_transferOwnership(address(0));
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
_transferOwnership(newOwner);
}
/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Internal function without access restriction.
*/
function _transferOwnership(address newOwner) internal virtual {
address oldOwner = _owner;
_owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
}
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.8.0) (security/ReentrancyGuard.sol)
pragma solidity ^0.8.0;
/**
* @dev Contract module that helps prevent reentrant calls to a function.
*
* Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier
* available, which can be applied to functions to make sure there are no nested
* (reentrant) calls to them.
*
* Note that because there is a single `nonReentrant` guard, functions marked as
* `nonReentrant` may not call one another. This can be worked around by making
* those functions `private`, and then adding `external` `nonReentrant` entry
* points to them.
*
* TIP: If you would like to learn more about reentrancy and alternative ways
* to protect against it, check out our blog post
* https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
*/
abstract contract ReentrancyGuard {
// Booleans are more expensive than uint256 or any type that takes up a full
// word because each write operation emits an extra SLOAD to first read the
// slot's contents, replace the bits taken up by the boolean, and then write
// back. This is the compiler's defense against contract upgrades and
// pointer aliasing, and it cannot be disabled.
// The values being non-zero value makes deployment a bit more expensive,
// but in exchange the refund on every call to nonReentrant will be lower in
// amount. Since refunds are capped to a percentage of the total
// transaction's gas, it is best to keep them low in cases like this one, to
// increase the likelihood of the full refund coming into effect.
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
/**
* @dev Prevents a contract from calling itself, directly or indirectly.
* Calling a `nonReentrant` function from another `nonReentrant`
* function is not supported. It is possible to prevent this from happening
* by making the `nonReentrant` function external, and making it call a
* `private` function that does the actual work.
*/
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
// On the first call to nonReentrant, _status will be _NOT_ENTERED
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
// Any calls to nonReentrant after this point will fail
_status = _ENTERED;
}
function _nonReentrantAfter() private {
// By storing the original value once again, a refund is triggered (see
// https://eips.ethereum.org/EIPS/eip-2200)
_status = _NOT_ENTERED;
}
/**
* @dev Returns true if the reentrancy guard is currently set to "entered", which indicates there is a
* `nonReentrant` function in the call stack.
*/
function _reentrancyGuardEntered() internal view returns (bool) {
return _status == _ENTERED;
}
}
//██████╗ █████╗ ██╗ █████╗ ██████╗ ██╗███╗ ██╗
//██╔══██╗██╔══██╗██║ ██╔══██╗██╔══██╗██║████╗ ██║
//██████╔╝███████║██║ ███████║██║ ██║██║██╔██╗ ██║
//██╔═══╝ ██╔══██║██║ ██╔══██║██║ ██║██║██║╚██╗██║
//██║ ██║ ██║███████╗██║ ██║██████╔╝██║██║ ╚████║
//╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═══╝
//SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.20;
import "./oz/interfaces/IERC20.sol";
import "./oz/libraries/SafeERC20.sol";
import "./oz/utils/ReentrancyGuard.sol";
import "./libraries/QuestDataTypes.sol";
import "./interfaces/IQuestBoard.sol";
import "./interfaces/IRingsVoter.sol";
import "./MultiMerkleDistributor.sol";
import "./utils/Owner.sol";
import "./libraries/Errors.sol";
/** @title Warden Quest Board V2 - Rings protocol version */
/// @author Paladin
/*
V2 of Quest Board allowing to blacklist or whitelist veNFTs for Rings protocol
and chose between fixed or ranged rewards distribution
*/
contract QuestBoardRings is IQuestBoard, Owner, ReentrancyGuard {
using SafeERC20 for IERC20;
// Storage
/** @notice Address of the Voter contract */
address public immutable VOTER;
/** @notice Amount of decimals for the veNFT */
uint256 public immutable VOTE_TOKEN_DECIMALS;
/** @notice Seconds in a Week */
uint256 private constant WEEK = 604800;
/** @notice 1e18 scale */
uint256 private constant UNIT = 1e18;
/** @notice Max BPS value (100%) */
uint256 private constant MAX_BPS = 10000;
/** @notice Delay where contract can be unkilled */
uint256 private constant KILL_DELAY = 2 * 604800; //2 weeks
/** @notice Max VoterList size */
uint256 private constant MAX_VOTERLIST_SIZE = 10;
/** @notice ID for the next Quest to be created */
uint256 public nextID;
/** @notice List of Quest (indexed by ID) */
// ID => Quest
mapping(uint256 => Quest) public quests;
/** @notice List of timestamp periods the Quest is active in */
// QuestID => Periods (timestamps)
mapping(uint256 => uint48[]) private questPeriods;
/** @notice Mapping of all state for each period of each Quest */
// QuestID => period => QuestDataTypes.PeriodState
mapping(uint256 => mapping(uint256 => QuestDataTypes.PeriodState)) public periodStateByQuest;
/** @notice Mapping of distributed reward amounts for each period of each Quest */
// QuestID => period => uint256
mapping(uint256 => mapping(uint256 => uint256)) public periodAmountDistributedByQuest;
/** @notice Original Reward per period for Rollover Quests */
// ID => amount
mapping(uint256 => uint256) public originalRewardPerPeriod;
/** @notice Used Reward per period for Rollover Quests (when closing a period) */
// ID => period => amount
mapping(uint256 => mapping(uint256 => uint256)) private usedRewardPerPeriod;
/** @notice All the Quests present in this period */
// period => array of Quest
mapping(uint256 => uint256[]) private questsByPeriod;
/** @notice All the Quests present in this period for each gauge */
// gauge => period => array of Quest
mapping(address => mapping(uint256 => uint256[])) private questsByGaugeByPeriod;
/** @notice Mapping of Distributors used by each Quest to send rewards */
// ID => Distributor
mapping(uint256 => address) public questDistributors;
/** @notice Amount not distributed, for Quest creators to redeem */
mapping(uint256 => uint256) public questWithdrawableAmount;
/** @notice Mapping of voter list (blacklist or whitelist) for each Quest, as NFT IDs */
// ID => VoterList
mapping(uint256 => uint256[]) private questVoterList;
/** @notice Platform fees ratio (in BPS) */
uint256 public platformFeeRatio = 400;
/** @notice Mapping of specific fee ratio for some Quest creators */
// Creator => specific fee
mapping(address => uint256) public customPlatformFeeRatio;
/** @notice Minimum Objective required */
uint256 public objectiveMinimalThreshold;
/** @notice Address of the Chest to receive platform fees */
address public questChest;
/** @notice Address of the reward Distributor contract */
address public distributor;
/** @notice Mapping of addresses allowed to call manager methods */
mapping(address => bool) private approvedManagers;
/** @notice Whitelisted tokens that can be used as reward tokens */
mapping(address => bool) public whitelistedTokens;
/** @notice Min rewardPerVote per token (to avoid spam creation of useless Quest) */
mapping(address => uint256) public minRewardPerVotePerToken;
/** @notice Boolean, true if the cotnract was killed, stopping main user functions */
bool public isKilled;
/** @notice Timestamp when the contract was killed */
uint256 public killTs;
// Modifiers
/** @notice Check the caller is either the admin or an approved manager */
modifier onlyAllowed(){
if(!approvedManagers[msg.sender] && msg.sender != owner()) revert Errors.CallerNotAllowed();
_;
}
/** @notice Check that contract was not killed */
modifier isAlive(){
if(isKilled) revert Errors.Killed();
_;
}
/** @notice Check that contract was initialized */
modifier isInitialized(){
if(distributor == address(0)) revert Errors.NotInitialized();
_;
}
// Constructor
constructor(address _voter, address _chest, uint256 _veNFTDecimals){
if(
_voter == address(0)
|| _chest == address(0)
) revert Errors.AddressZero();
VOTER = _voter;
questChest = _chest;
VOTE_TOKEN_DECIMALS = _veNFTDecimals;
objectiveMinimalThreshold = 100 * UNIT;
}
/**
* @notice Initialize the contract
* @param _distributor Address of the Distributor
*/
function init(address _distributor) external onlyOwner {
if(distributor != address(0)) revert Errors.AlreadyInitialized();
if(_distributor == address(0)) revert Errors.AddressZero();
distributor = _distributor;
emit Init(_distributor);
}
// View Functions
/**
* @notice Returns the current Period for the contract
* @dev Returns the current Period for the contract
*/
function getCurrentPeriod() public view returns(uint256) {
return (block.timestamp / WEEK) * WEEK;
}
/**
* @notice Returns the list of all Quest IDs active on a given period
* @dev Returns the list of all Quest IDs active on a given period
* @param period Timestamp of the period
* @return uint256[] : Quest IDs for the period
*/
function getQuestIdsForPeriodForGauge(address gauge, uint256 period) external view returns(uint256[] memory) {
period = (period / WEEK) * WEEK;
return questsByGaugeByPeriod[gauge][period];
}
/**
* @notice Returns the list of all Quest IDs active on a given period
* @dev Returns the list of all Quest IDs active on a given period
* @param period Timestamp of the period
* @return uint256[] : Quest IDs for the period
*/
function getQuestIdsForPeriod(uint256 period) external view returns(uint256[] memory) {
period = (period / WEEK) * WEEK;
return questsByPeriod[period];
}
/**
* @notice Returns all periods for a Quest
* @dev Returns all period timestamps for a Quest ID
* @param questID ID of the Quest
* @return uint256[] : List of period timestamps
*/
function getAllPeriodsForQuestId(uint256 questID) external view returns(uint48[] memory) {
return questPeriods[questID];
}
/**
* @notice Returns the number of periods to come for a given Quest
* @dev Returns the number of periods to come for a given Quest
* @param questID ID of the Quest
* @return uint : remaining duration (non active periods)
*/
function _getRemainingDuration(uint256 questID) internal view returns(uint256) {
// Since we have the current period, the start period for the Quest, and each period is 1 WEEK
// We can find the number of remaining periods in the Quest simply by dividing the remaining time between
// currentPeriod and the last QuestPeriod start, plus 1 WEEK, by a WEEK.
// If the current period is the last period of the Quest, we want to return 1
if(questPeriods[questID].length == 0) revert Errors.EmptyQuest();
uint256 lastPeriod = questPeriods[questID][questPeriods[questID].length - 1];
uint256 currentPeriod = getCurrentPeriod();
return lastPeriod < currentPeriod ? 0: ((lastPeriod - currentPeriod) + WEEK) / WEEK;
}
/**
* @notice Returns the address of the Quest creator
* @dev Returns the address of the Quest creator
* @param questID ID of the Quest
* @return address : creator of the Quest
*/
function getQuestCreator(uint256 questID) external view returns(address){
return quests[questID].creator;
}
/**
* @notice Returns the voter list for a given Quest
* @param questID ID of the Quest
* @return uint256[] : vote list
*/
function getQuestVoterList(uint256 questID) external view returns(uint256[] memory){
return questVoterList[questID];
}
/**
* @notice Returns the current reduced bias for a given gauge (based on a Quest's voter list)
* @param questID ID of the Quest
* @return uint256 : current reduced bias
*/
function getCurrentReducedBias(uint256 questID) external view returns(uint256) {
uint256 nextPeriod = getCurrentPeriod() + WEEK;
return getReducedBias(nextPeriod, questID);
}
function _convertVotes(uint256 votes) internal view returns(uint256) {
if(VOTE_TOKEN_DECIMALS == 18) return votes;
else if(VOTE_TOKEN_DECIMALS > 18) {
return votes / (10 ** (VOTE_TOKEN_DECIMALS - 18));
}
else {
return votes * (10 ** (18 - VOTE_TOKEN_DECIMALS));
}
}
/**
* @notice Returns the reduced bias for a given gauge for a given period (based on a Quest's voter list)
* @param period timestamp of the period
* @param questID ID of the Quest
* @return uint256 : current reduced bias
*/
function getReducedBias(uint256 period, uint256 questID) public view returns(uint256) {
uint256[] memory voterList = questVoterList[questID];
address gauge = quests[questID].gauge;
QuestDataTypes.QuestVoteType questType = quests[questID].types.voteType;
IRingsVoter voter = IRingsVoter(VOTER);
uint256 voterListSumBias;
uint256 voterListLength = voterList.length;
for(uint256 i; i < voterListLength;) {
uint256 userVotes = _convertVotes(voter.getNftVotesOnGaugeAtPeriod(voterList[i], gauge, period));
voterListSumBias += userVotes;
unchecked { i++; }
}
// For a WHITELIST type, simply return the sum of voters bias
if(questType == QuestDataTypes.QuestVoteType.WHITELIST) return voterListSumBias;
// Get the bias of the Gauge for the given period
uint256 periodAdjustedBias = _convertVotes(voter.getGaugeVotesAtPeriod(gauge, period));
// If the Quest is a Blacklist, we need to remove the bias of the voters
if(questType == QuestDataTypes.QuestVoteType.BLACKLIST) {
periodAdjustedBias = voterListSumBias >= periodAdjustedBias ? 0 : periodAdjustedBias - voterListSumBias;
}
return periodAdjustedBias;
}
// Functions
/**
* @notice Creates a fixed rewards Quest based on the given parameters
* @dev Creates a Quest based on the given parameters & the given types with the Fixed Rewards type
* @param gauge Address of the gauge
* @param rewardToken Address of the reward token
* @param startNextPeriod (bool) true to start the Quest the next period
* @param duration Duration of the Quest (in weeks)
* @param rewardPerVote Amount of reward/vote (in wei)
* @param totalRewardAmount Total amount of rewards available for the full Quest duration
* @param feeAmount Amount of fees paid at creation
* @param voteType Vote type for the Quest
* @param closeType Close type for the Quest
* @param voterList List of veNFT IDs for the Quest (to be used for Blacklist or Whitelist)
* @return uint256 : ID of the newly created Quest
*/
function createFixedQuest(
address gauge,
address rewardToken,
bool startNextPeriod,
uint48 duration,
uint256 rewardPerVote,
uint256 totalRewardAmount,
uint256 feeAmount,
QuestDataTypes.QuestVoteType voteType,
QuestDataTypes.QuestCloseType closeType,
uint256[] calldata voterList
) external nonReentrant isAlive isInitialized returns(uint256) {
// Set the Quest Types for the new Quest
QuestTypes memory types = QuestTypes({
voteType: voteType,
rewardsType: QuestDataTypes.QuestRewardsType.FIXED,
closeType: closeType
});
return _createQuest(
gauge,
rewardToken,
types,
startNextPeriod,
duration,
rewardPerVote,
rewardPerVote,
totalRewardAmount,
feeAmount,
voterList
);
}
/**
* @notice Creates a ranged rewards Quest based on the given parameters
* @dev Creates a Quest based on the given parameters & the given types with the Ranged Rewards type
* @param gauge Address of the gauge
* @param rewardToken Address of the reward token
* @param startNextPeriod (bool) true to start the Quest the next period
* @param duration Duration of the Quest (in weeks)
* @param minRewardPerVote Minimum amount of reward/vote (in wei)
* @param maxRewardPerVote Maximum amount of reward/vote (in wei)
* @param totalRewardAmount Total amount of rewards available for the full Quest duration
* @param feeAmount Amount of fees paid at creation
* @param voteType Vote type for the Quest
* @param closeType Close type for the Quest
* @param voterList List of veNFT IDs for the Quest (to be used for Blacklist or Whitelist)
* @return uint256 : ID of the newly created Quest
*/
function createRangedQuest(
address gauge,
address rewardToken,
bool startNextPeriod,
uint48 duration,
uint256 minRewardPerVote,
uint256 maxRewardPerVote,
uint256 totalRewardAmount,
uint256 feeAmount,
QuestDataTypes.QuestVoteType voteType,
QuestDataTypes.QuestCloseType closeType,
uint256[] calldata voterList
) external nonReentrant isAlive isInitialized returns(uint256) {
// Set the Quest Types for the new Quest
QuestTypes memory types = QuestTypes({
voteType: voteType,
rewardsType: QuestDataTypes.QuestRewardsType.RANGE,
closeType: closeType
});
return _createQuest(
gauge,
rewardToken,
types,
startNextPeriod,
duration,
minRewardPerVote,
maxRewardPerVote,
totalRewardAmount,
feeAmount,
voterList
);
}
/**
* @notice Creates a Quest based on the given parameters
* @dev Creates a Quest based on the given parameters & the given types
* @param gauge Address of the gauge
* @param rewardToken Address of the reward token
* @param types Quest Types (Rewards, Vote & Close)
* @param startNextPeriod (bool) true to start the Quest the next period
* @param duration Duration of the Quest (in weeks)
* @param minRewardPerVote Minimum amount of reward/vote (in wei)
* @param maxRewardPerVote Maximum amount of reward/vote (in wei)
* @param totalRewardAmount Total amount of rewards available for the full Quest duration
* @param feeAmount Amount of fees paid at creation
* @param voterList List of veNFT IDs for the Quest (to be used for Blacklist or Whitelist)
* @return newQuestID (uint256) : ID of the newly created Quest
*/
function _createQuest(
address gauge,
address rewardToken,
QuestTypes memory types,
bool startNextPeriod,
uint48 duration,
uint256 minRewardPerVote,
uint256 maxRewardPerVote,
uint256 totalRewardAmount,
uint256 feeAmount,
uint256[] calldata voterList
) internal returns(uint256 newQuestID) {
// Local memory variables
CreateVars memory vars;
vars.creator = msg.sender;
// Check all parameters
if(gauge == address(0) || rewardToken == address(0)) revert Errors.AddressZero();
if(!(IRingsVoter(VOTER).isGauge(gauge) && IRingsVoter(VOTER).isAlive(gauge))) revert Errors.InvalidGauge();
if(!whitelistedTokens[rewardToken]) revert Errors.TokenNotWhitelisted();
if(duration == 0) revert Errors.IncorrectDuration();
if(minRewardPerVote == 0 || maxRewardPerVote == 0 || totalRewardAmount == 0 || feeAmount == 0) revert Errors.NullAmount();
if(minRewardPerVote < minRewardPerVotePerToken[rewardToken]) revert Errors.RewardPerVoteTooLow();
if(minRewardPerVote > maxRewardPerVote) revert Errors.MinValueOverMaxValue();
if(types.rewardsType == QuestDataTypes.QuestRewardsType.FIXED && minRewardPerVote != maxRewardPerVote) revert Errors.InvalidQuestType();
if((totalRewardAmount * _getFeeRatio(msg.sender))/MAX_BPS != feeAmount) revert Errors.IncorrectFeeAmount();
if(types.closeType == QuestDataTypes.QuestCloseType.DISTRIBUTE) revert Errors.QuestTypeForbidden();
// Calculate the reward per period, and the max vote objective per period
vars.rewardPerPeriod = totalRewardAmount / duration;
vars.minObjective = (vars.rewardPerPeriod * UNIT) / maxRewardPerVote;
if(vars.minObjective < objectiveMinimalThreshold) revert Errors.ObjectiveTooLow();
// Pull all the rewards in this contract
IERC20(rewardToken).safeTransferFrom(vars.creator, address(this), totalRewardAmount);
// And transfer the fees from the Quest creator to the Chest contract
IERC20(rewardToken).safeTransferFrom(vars.creator, questChest, feeAmount);
// Get the period when the Quest starts (current or next period)
vars.startPeriod = getCurrentPeriod();
if(startNextPeriod) vars.startPeriod += WEEK;
// Get the ID for that new Quest and increment the nextID counter
newQuestID = nextID;
unchecked{ ++nextID; }
// Fill the Quest struct data
quests[newQuestID] = Quest({
creator: vars.creator,
rewardToken: rewardToken,
gauge: gauge,
duration: duration,
periodStart: safe48(vars.startPeriod),
totalRewardAmount: totalRewardAmount,
rewardAmountPerPeriod: vars.rewardPerPeriod,
minRewardPerVote: minRewardPerVote,
maxRewardPerVote: maxRewardPerVote,
minObjectiveVotes: vars.minObjective,
maxObjectiveVotes: (vars.rewardPerPeriod * UNIT) / minRewardPerVote,
types: types
});
//Set the current Distributor as the one to receive the rewards for users for that Quest
questDistributors[newQuestID] = distributor;
// Iterate on periods based on Quest duration
vars.periodIterator = vars.startPeriod;
for(uint256 i; i < duration;){
// Add the Quest on the list of Quests active on the period
questsByPeriod[vars.periodIterator].push(newQuestID);
questsByGaugeByPeriod[gauge][vars.periodIterator].push(newQuestID);
// Set the state of the period as ACTIVE
periodStateByQuest[newQuestID][vars.periodIterator] = QuestDataTypes.PeriodState.ACTIVE;
// And add the period in the list of periods of the Quest
questPeriods[newQuestID].push(safe48(vars.periodIterator));
vars.periodIterator += WEEK;
unchecked{ ++i; }
}
vars.voterLength = voterList.length;
if(vars.voterLength > 0) {
if(vars.voterLength > MAX_VOTERLIST_SIZE) revert Errors.MaxListSize();
for(uint256 i; i < vars.voterLength;){
_addToVoterList(newQuestID, voterList[i]);
unchecked { ++i; }
}
}
if(types.closeType == QuestDataTypes.QuestCloseType.ROLLOVER){
originalRewardPerPeriod[newQuestID] = vars.rewardPerPeriod;
}
// Add that Quest & the reward token in the Distributor
if(!MultiMerkleDistributor(distributor).addQuest(newQuestID, rewardToken)) revert Errors.DisitributorFail();
emit NewQuest(
newQuestID,
msg.sender,
gauge,
rewardToken,
duration,
vars.startPeriod
);
}
/**
* @notice Adds a given address to a Quest's voter list
* @dev Adds a given address to a Quest's voter list
* @param questID ID of the Quest
* @param nftID ID of the veNFT
*/
function _addToVoterList(uint256 questID, uint256 nftID) internal {
//We don't want to have 2x the same address in the list
uint256[] memory _list = questVoterList[questID];
uint256 length = _list.length;
for(uint256 i; i < length;){
if(_list[i] == nftID) revert Errors.AlreadyListed();
unchecked {
++i;
}
}
questVoterList[questID].push(nftID);
}
/**
* @notice Increases the duration of a Quest
* @dev Adds more QuestPeriods and extends the duration of a Quest
* @param questID ID of the Quest
* @param addedDuration Number of period to add
* @param addedRewardAmount Amount of reward to add for the new periods (in wei)
* @param feeAmount Platform fees amount (in wei)
*/
function extendQuestDuration(
uint256 questID,
uint48 addedDuration,
uint256 addedRewardAmount,
uint256 feeAmount
) external nonReentrant isAlive isInitialized {
// Local memory variables
ExtendVars memory vars;
if(questID >= nextID) revert Errors.InvalidQuestID();
if(msg.sender != quests[questID].creator) revert Errors.CallerNotAllowed();
if(addedRewardAmount == 0 || feeAmount == 0) revert Errors.NullAmount();
if(addedDuration == 0) revert Errors.IncorrectAddDuration();
// We take data from the last period of the Quest to account for any other changes in the Quest parameters
if(questPeriods[questID].length == 0) revert Errors.EmptyQuest();
vars.lastPeriod = questPeriods[questID][questPeriods[questID].length - 1];
if(quests[questID].periodStart >= block.timestamp) revert Errors.QuestNotStarted();
if(vars.lastPeriod < getCurrentPeriod()) revert Errors.ExpiredQuest();
// Check that the given amounts are correct
vars.rewardPerPeriod = quests[questID].rewardAmountPerPeriod;
if(quests[questID].types.closeType == QuestDataTypes.QuestCloseType.ROLLOVER){
vars.rewardPerPeriod = originalRewardPerPeriod[questID];
}
if((vars.rewardPerPeriod * addedDuration) != addedRewardAmount) revert Errors.IncorrectAddedRewardAmount();
if((addedRewardAmount * _getFeeRatio(msg.sender))/MAX_BPS != feeAmount) revert Errors.IncorrectFeeAmount();
vars.gauge = quests[questID].gauge;
vars.rewardToken = quests[questID].rewardToken;
// Pull all the rewards in this contract
IERC20(vars.rewardToken).safeTransferFrom(msg.sender, address(this), addedRewardAmount);
// And transfer the fees from the Quest creator to the Chest contract
IERC20(vars.rewardToken).safeTransferFrom(msg.sender, questChest, feeAmount);
vars.periodIterator = ((vars.lastPeriod + WEEK) / WEEK) * WEEK;
// Update the Quest struct with added reward admounts & added duration
quests[questID].totalRewardAmount += addedRewardAmount;
quests[questID].duration += addedDuration;
// Add QuestPeriods for the new added duration
for(uint256 i; i < addedDuration;){
questsByPeriod[vars.periodIterator].push(questID);
questsByGaugeByPeriod[quests[questID].gauge][vars.periodIterator].push(questID);
questPeriods[questID].push(safe48(vars.periodIterator));
// Set the state of the period as ACTIVE
periodStateByQuest[questID][vars.periodIterator] = QuestDataTypes.PeriodState.ACTIVE;
vars.periodIterator += WEEK;
unchecked{ ++i; }
}
emit ExtendQuestDuration(questID, addedDuration, addedRewardAmount);
}
/**
* @notice Updates the parametes of the Quest
* @dev Updates the reward/vote parameters, allowing to update the Quest objectives too
* @param questID ID of the Quest
* @param newMinRewardPerVote New min reward/vote value (in wei)
* @param newMaxRewardPerVote New max reward/vote value (in wei)
* @param addedPeriodRewardAmount Amount of reward to add for each period (in wei)
* @param addedTotalRewardAmount Amount of reward to add for all periods (in wei)
* @param feeAmount Platform fees amount (in wei)
*/
function updateQuestParameters(
uint256 questID,
uint256 newMinRewardPerVote,
uint256 newMaxRewardPerVote,
uint256 addedPeriodRewardAmount,
uint256 addedTotalRewardAmount,
uint256 feeAmount
) external nonReentrant isAlive isInitialized {
// Local memory variables
UpdateVars memory vars;
Quest storage _quest = quests[questID];
if(questID >= nextID) revert Errors.InvalidQuestID();
if(msg.sender != _quest.creator) revert Errors.CallerNotAllowed();
if(newMinRewardPerVote == 0 || newMaxRewardPerVote == 0) revert Errors.NullAmount();
if(newMinRewardPerVote > newMaxRewardPerVote) revert Errors.MinValueOverMaxValue();
if(_quest.types.rewardsType == QuestDataTypes.QuestRewardsType.FIXED && newMinRewardPerVote != newMaxRewardPerVote) revert Errors.InvalidQuestType();
// Check the reamining duration, and that the given reward amounts are correct
vars.remainingDuration = _getRemainingDuration(questID); //Also handles the Empty Quest check
if(vars.remainingDuration == 0) revert Errors.ExpiredQuest();
if(_quest.periodStart >= block.timestamp) revert Errors.QuestNotStarted();
if((addedPeriodRewardAmount * vars.remainingDuration) != addedTotalRewardAmount) revert Errors.IncorrectAddedRewardAmount();
if((addedTotalRewardAmount * _getFeeRatio(msg.sender))/MAX_BPS != feeAmount) revert Errors.IncorrectFeeAmount();
// The new min reward amount must be higher
vars.currentPeriod = getCurrentPeriod();
if(newMinRewardPerVote < _quest.minRewardPerVote) revert Errors.LowerRewardPerVote();
// Get the amount of reward for each period
vars.newRewardPerPeriod = _quest.rewardAmountPerPeriod + addedPeriodRewardAmount;
if(quests[questID].types.closeType == QuestDataTypes.QuestCloseType.ROLLOVER){
originalRewardPerPeriod[questID] += addedPeriodRewardAmount;
}
// Calculate the new max vote objective, and the min vote objective based on the Quest Rewards type
vars.newMaxObjective = (vars.newRewardPerPeriod * UNIT) / newMinRewardPerVote;
vars.newMinObjective;
if(_quest.types.rewardsType == QuestDataTypes.QuestRewardsType.RANGE) {
vars.newMinObjective = (vars.newRewardPerPeriod * UNIT) / newMaxRewardPerVote;
} else {
vars.newMinObjective = vars.newMaxObjective;
}
if(vars.newMinObjective < _quest.minObjectiveVotes) revert Errors.NewObjectiveTooLow();
if(addedTotalRewardAmount > 0) {
address rewardToken = _quest.rewardToken;
// Pull all the rewards in this contract
IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), addedTotalRewardAmount);
// And transfer the fees from the Quest creator to the Chest contract
IERC20(rewardToken).safeTransferFrom(msg.sender, questChest, feeAmount);
}
// Update the Quest struct with the added reward amount
_quest.totalRewardAmount += addedTotalRewardAmount;
_quest.minRewardPerVote = newMinRewardPerVote;
_quest.maxRewardPerVote = newMaxRewardPerVote;
_quest.minObjectiveVotes = vars.newMinObjective;
_quest.maxObjectiveVotes = vars.newMaxObjective;
_quest.rewardAmountPerPeriod = vars.newRewardPerPeriod;
emit UpdateQuestParameters(
questID,
vars.currentPeriod,
newMinRewardPerVote,
newMaxRewardPerVote,
addedPeriodRewardAmount
);
}
/**
* @notice Adds a given list of addresses to a Quest's voter list
* @param questID ID of the Quest
* @param nftIDs list of veNFT IDs
*/
function addToVoterList(uint256 questID, uint256[] calldata nftIDs) external {
uint256 length = nftIDs.length;
if(length == 0) revert Errors.EmptyArray();
if(quests[questID].gauge == address(0)) revert Errors.InvalidQuestID();
if(msg.sender != quests[questID].creator) revert Errors.CallerNotAllowed();
if(length + questVoterList[questID].length > MAX_VOTERLIST_SIZE) revert Errors.MaxListSize();
for(uint256 i = 0; i < length;){
_addToVoterList(questID, nftIDs[i]);
unchecked {
++i;
}
}
emit VoterListUpdated(questID);
}
/**
* @notice Removes a given address from a Quest's voter list
* @param questID ID of the Quest
* @param nftID ID of the veNFT
*/
function removeFromVoterList(uint256 questID, uint256 nftID) external {
if(quests[questID].gauge == address(0)) revert Errors.InvalidQuestID();
if(msg.sender != quests[questID].creator) revert Errors.CallerNotAllowed();
uint256[] memory _list = questVoterList[questID];
uint256 length = _list.length;
for(uint256 i; i < length;){
if(_list[i] == nftID){
if(i != length - 1){
questVoterList[questID][i] = _list[length - 1];
}
questVoterList[questID].pop();
emit VoterListUpdated(questID);
return;
}
unchecked {
++i;
}
}
}
/**
* @notice Withdraw all undistributed rewards from Closed Quest Periods
* @dev Withdraw all undistributed rewards from Closed Quest Periods
* @param questID ID of the Quest
* @param recipient Address to send the reward tokens to
*/
function withdrawUnusedRewards(uint256 questID, address recipient) external nonReentrant isAlive isInitialized {
if(questID >= nextID) revert Errors.InvalidQuestID();
if(msg.sender != quests[questID].creator) revert Errors.CallerNotAllowed();
if(recipient == address(0)) revert Errors.AddressZero();
// Total amount available to withdraw
uint256 withdrawAmount = questWithdrawableAmount[questID];
questWithdrawableAmount[questID] = 0;
// If there is a non null amount of token to withdraw, execute a transfer
if(withdrawAmount != 0){
address rewardToken = quests[questID].rewardToken;
IERC20(rewardToken).safeTransfer(recipient, withdrawAmount);
emit WithdrawUnusedRewards(questID, recipient, withdrawAmount);
}
}
function multipleWithdrawUnusedRewards(uint256[] calldata questIDs, address recipient) external nonReentrant isAlive isInitialized {
uint256 length = questIDs.length;
if(length == 0) revert Errors.EmptyArray();
if(recipient == address(0)) revert Errors.AddressZero();
address lastToken;
uint256 transferAmount;
for(uint256 i; i < length;){
if(questIDs[i] >= nextID) revert Errors.InvalidQuestID();
if(msg.sender != quests[questIDs[i]].creator) revert Errors.CallerNotAllowed();
// Total amount available to withdraw
uint256 withdrawAmount = questWithdrawableAmount[questIDs[i]];
questWithdrawableAmount[questIDs[i]] = 0;
// If there is a non null amount of token to withdraw, execute a transfer
if(withdrawAmount != 0){
address rewardToken = quests[questIDs[i]].rewardToken;
if(rewardToken != lastToken){
if(lastToken != address(0)){
IERC20(lastToken).safeTransfer(recipient, transferAmount);
transferAmount = 0;
}
lastToken = rewardToken;
transferAmount += withdrawAmount;
} else {
transferAmount += withdrawAmount;
}
emit WithdrawUnusedRewards(questIDs[i], recipient, withdrawAmount);
}
unchecked{ ++i; }
}
if(lastToken != address(0) && transferAmount != 0){
IERC20(lastToken).safeTransfer(recipient, transferAmount);
}
}
/**
* @notice Emergency withdraws all undistributed rewards from Closed Quest Periods & all rewards for Active Periods
* @dev Emergency withdraws all undistributed rewards from Closed Quest Periods & all rewards for Active Periods
* @param questID ID of the Quest
* @param recipient Address to send the reward tokens to
*/
function emergencyWithdraw(uint256 questID, address recipient) external nonReentrant {
if(!isKilled) revert Errors.NotKilled();
if(block.timestamp < killTs + KILL_DELAY) revert Errors.KillDelayNotExpired();
if(questID >= nextID) revert Errors.InvalidQuestID();
if(msg.sender != quests[questID].creator) revert Errors.CallerNotAllowed();
if(recipient == address(0)) revert Errors.AddressZero();
// Total amount to emergency withdraw
uint256 withdrawAmount = questWithdrawableAmount[questID];
questWithdrawableAmount[questID] = 0;
uint48[] memory _questPeriods = questPeriods[questID];
uint256 length = _questPeriods.length;
Quest storage _questData = quests[questID];
for(uint256 i; i < length;){
// For ACTIVE periods
if(periodStateByQuest[questID][_questPeriods[i]] == QuestDataTypes.PeriodState.ACTIVE){
// For the active period, and the next ones, withdraw the total reward amount
withdrawAmount += _questData.rewardAmountPerPeriod;
}
unchecked{ ++i; }
}
_questData.rewardAmountPerPeriod = 0;
// If the total amount to emergency withdraw is non_null, execute a transfer
if(withdrawAmount != 0){
IERC20(quests[questID].rewardToken).safeTransfer(recipient, withdrawAmount);
emit EmergencyWithdraw(questID, recipient, withdrawAmount);
}
}
/**
* @notice Get the fee ratio for a given Quest creator
* @dev Returns the custom fee ratio for a Quest creator if set, otherwise returns the general fee ratio
* @param questCreator address of the Quest creator
* @return uint256 : fee ratio
*/
function _getFeeRatio(address questCreator) internal view returns(uint256) {
return customPlatformFeeRatio[questCreator] != 0 ? customPlatformFeeRatio[questCreator] : platformFeeRatio;
}
// Manager functions
/**
* @notice Gets the amount of rewards to be distributed for the period
* @dev Gets the amount of rewards to be distributed for the
* @param questRewardType Rewards type for the Quest
* @param periodBias Bias of the gauge (reduced if nedded) for the given period
* @param _questData Data for the Quest
* @return uint256 : Amount to be distributed
*/
function _getDistributionAmount(
QuestDataTypes.QuestRewardsType questRewardType,
uint256 periodBias,
Quest memory _questData
) internal pure returns(uint256) {
// Here, if the Gauge Bias is equal or greater than the objective,
// set all the period reward to be distributed.
// If the bias is less, we take that bias, and calculate the amount of rewards based
// on the rewardPerVote & the Gauge bias
// If the votes received exceed the max objective of the Quest (for both types)
// Distribute all the rewards for the period
if(periodBias >= _questData.maxObjectiveVotes) return _questData.rewardAmountPerPeriod;
if(questRewardType == QuestDataTypes.QuestRewardsType.FIXED) {
return (periodBias * _questData.minRewardPerVote) / UNIT;
} else { // For QuestDataTypes.QuestRewardsType.RANGE
// If the bias is under the minimum objective, use max reward/vote
if(periodBias <= _questData.minObjectiveVotes) return (periodBias * _questData.maxRewardPerVote) / UNIT;
else return _questData.rewardAmountPerPeriod;
}
}
/**
* @notice Handles the Quest period undistributed rewards
* @dev Handles the Quest period undistributed rewards based on the Quest Close type
* @param questID ID of the Quest
* @param currentPeriod Timestamp of the current period
* @param questCloseType Close type for the Quest
* @param undistributedAmount Amount of token not distributed for voter rewards
*/
function _handleUndistributedRewards(
uint256 questID,
uint256 currentPeriod,
QuestDataTypes.QuestCloseType questCloseType,
uint256 undistributedAmount
) internal {
if(questCloseType == QuestDataTypes.QuestCloseType.ROLLOVER) {
// Since this type is only allowed for FIXED Rewards Quests
// We simply recalculate the next period reward/vote based on the current Objective
uint256 nextPeriod = currentPeriod + WEEK;
// If not the last period
if(nextPeriod > questPeriods[questID][questPeriods[questID].length - 1]) {
// This the Quest last period, no period to rollover to
questWithdrawableAmount[questID] += undistributedAmount;
return;
}
Quest storage _questData = quests[questID];
usedRewardPerPeriod[questID][currentPeriod] = _questData.rewardAmountPerPeriod;
// Calculate the new period parameters by adding undistributed rewards to the base period reward amount
// or reset to the original reward amount if the undistributed amount is 0
// & update the next period parameters based on new calculated parameters
uint256 newRewardPerPeriod = originalRewardPerPeriod[questID];
if(undistributedAmount > 0) {
newRewardPerPeriod += (undistributedAmount);
}
uint256 newMinRewardPerVote = (newRewardPerPeriod * UNIT) / _questData.maxObjectiveVotes;
uint256 newMaxRewardPerVote = (newRewardPerPeriod * UNIT) / _questData.minObjectiveVotes;
_questData.minRewardPerVote = newMinRewardPerVote;
_questData.maxRewardPerVote = newMaxRewardPerVote;
_questData.rewardAmountPerPeriod = newRewardPerPeriod;
emit RewardsRollover(questID, newRewardPerPeriod, newMinRewardPerVote, newMaxRewardPerVote);
} else { // For QuestDataTypes.QuestCloseType.NORMAL
questWithdrawableAmount[questID] += undistributedAmount;
}
}
/**
* @notice Closes the Period, and all QuestPeriods for this period
* @dev Closes all QuestPeriod for the given period, calculating rewards to distribute & send them to distributor
* @param period Timestamp of the period
*/
function _closeQuestPeriod(uint256 period, uint256 questID) internal returns(bool) {
// We check that this period was not already closed
if(periodStateByQuest[questID][period] != QuestDataTypes.PeriodState.ACTIVE) return false;
Quest memory _quest = quests[questID];
periodStateByQuest[questID][period] = QuestDataTypes.PeriodState.CLOSED;
// Get the bias of the Gauge for the end of the period
uint256 periodAdjustedBias = getReducedBias(
period + WEEK,
questID
);
uint256 undistributedAmount;
if(periodAdjustedBias == 0) {
// Because we don't want to divide by 0 here since the bias is 0, we consider 0% completion
// => no rewards to be distributed
// We do not change _questPeriod.rewardAmountDistributed since the default value is already 0
undistributedAmount = _quest.rewardAmountPerPeriod;
}
else{
// Get the amount of rewards to be distributed
uint256 distributionAmount = _getDistributionAmount(_quest.types.rewardsType, periodAdjustedBias, _quest);
periodAmountDistributedByQuest[questID][period] = distributionAmount;
// And the rest is set as withdrawable amount, that the Quest creator can retrieve
undistributedAmount = _quest.rewardAmountPerPeriod - distributionAmount;
// Send the rewards to be distributed to the Distrubutor
address questDistributor = questDistributors[questID];
if(!MultiMerkleDistributor(questDistributor).addQuestPeriod(questID, period, distributionAmount)) revert Errors.DisitributorFail();
IERC20(_quest.rewardToken).safeTransfer(questDistributor, distributionAmount);
}
// Handle the undistributed rewards based on the Quest Close type
_handleUndistributedRewards(questID, period, _quest.types.closeType, undistributedAmount);
emit PeriodClosed(questID, period);
return true;
}
/**
* @notice Closes the Period, and all QuestPeriods for this period
* @dev Closes all QuestPeriod for the given period, calculating rewards to distribute & send them to distributor
* @param period Timestamp of the period
*/
function closeQuestPeriod(uint256 period) external nonReentrant isAlive isInitialized onlyAllowed returns(uint256 closed, uint256 skipped) {
period = (period / WEEK) * WEEK;
if(period == 0) revert Errors.InvalidPeriod();
if(period >= getCurrentPeriod()) revert Errors.PeriodStillActive();
if(questsByPeriod[period].length == 0) revert Errors.EmptyPeriod();
uint256[] memory questsForPeriod = questsByPeriod[period];
// For each QuestPeriod
uint256 length = questsForPeriod.length;
for(uint256 i = 0; i < length;){
bool result = _closeQuestPeriod(period, questsForPeriod[i]);
if(result) closed++;
else skipped++;
unchecked{ ++i; }
}
}
/**
* @notice Closes the given QuestPeriods for the Period
* @dev Closes the given QuestPeriods for the Period, calculating rewards to distribute & send them to distributor
* @param period Timestamp of the period
* @param questIDs List of the Quest IDs to close
*/
function closePartOfQuestPeriod(uint256 period, uint256[] calldata questIDs) external nonReentrant isAlive isInitialized onlyAllowed returns(uint256 closed, uint256 skipped) {
period = (period / WEEK) * WEEK;
uint256 questIDLength = questIDs.length;
if(questIDLength == 0) revert Errors.EmptyArray();
if(period == 0) revert Errors.InvalidPeriod();
if(period >= getCurrentPeriod()) revert Errors.PeriodStillActive();
if(questsByPeriod[period].length == 0) revert Errors.EmptyPeriod();
// For each QuestPeriod for the given Quest IDs list
for(uint256 i = 0; i < questIDLength;){
bool result = _closeQuestPeriod(period, questIDs[i]);
if(result) closed++;
else skipped++;
unchecked{ ++i; }
}
}
/**
* @notice Sets a list of QuestPeriods as disitrbuted, and adds the MerkleRoot to the Distributor contract for each
* @dev Loop and internal call to _addMerkleRoot()
* @param questIDs List of Quest IDs
* @param period Timestamp of the period
* @param totalAmounts List of sums of all rewards for the Merkle Tree
* @param merkleRoots List of MerkleRoots to add
*/
function addMultipleMerkleRoot(
uint256[] calldata questIDs,
uint256 period,
uint256[] calldata totalAmounts,
bytes32[] calldata merkleRoots
) external nonReentrant isAlive isInitialized onlyAllowed {
period = (period / WEEK) * WEEK;
uint256 length = questIDs.length;
if(length != merkleRoots.length) revert Errors.InequalArraySizes();
if(length != totalAmounts.length) revert Errors.InequalArraySizes();
for(uint256 i = 0; i < length;){
if(questIDs[i] >= nextID) revert Errors.InvalidQuestID();
if(merkleRoots[i] == 0) revert Errors.EmptyMerkleRoot();
if(totalAmounts[i] == 0) revert Errors.NullAmount();
// This also allows to check if the given period is correct => If not, the currentState is never set to CLOSED for the QuestPeriod
if(periodStateByQuest[questIDs[i]][period] != QuestDataTypes.PeriodState.CLOSED) revert Errors.PeriodNotClosed();
// Add the MerkleRoot to the Distributor & set the QuestPeriod as DISTRIBUTED
if(!MultiMerkleDistributor(questDistributors[questIDs[i]]).updateQuestPeriod(questIDs[i], period, totalAmounts[i], merkleRoots[i])) revert Errors.DisitributorFail();
periodStateByQuest[questIDs[i]][period] = QuestDataTypes.PeriodState.DISTRIBUTED;
unchecked{ ++i; }
}
}
/**
* @notice Whitelists a list of reward tokens
* @dev Whitelists a list of reward tokens
* @param newTokens List of reward tokens addresses
* @param minRewardPerVotes List of minimal threshold of reward per vote for the reward token
*/
function whitelistMultipleTokens(address[] calldata newTokens, uint256[] calldata minRewardPerVotes) external onlyAllowed {
uint256 length = newTokens.length;
if(length == 0) revert Errors.EmptyArray();
if(length != minRewardPerVotes.length) revert Errors.InequalArraySizes();
for(uint256 i = 0; i < length;){
if(newTokens[i] == address(0)) revert Errors.AddressZero();
if(minRewardPerVotes[i] == 0) revert Errors.InvalidParameter();
whitelistedTokens[newTokens[i]] = true;
minRewardPerVotePerToken[newTokens[i]] = minRewardPerVotes[i];
emit WhitelistToken(newTokens[i], minRewardPerVotes[i]);
unchecked{ ++i; }
}
}
/**
* @notice Updates a reward token parameters
* @dev Updates a reward token parameters
* @param newToken Address of the reward token
* @param newMinRewardPerVote New minimal threshold of reward per vote for the reward token
*/
function updateRewardToken(address newToken, uint256 newMinRewardPerVote) external onlyAllowed {
if(!whitelistedTokens[newToken]) revert Errors.TokenNotWhitelisted();
if(newMinRewardPerVote == 0) revert Errors.InvalidParameter();
minRewardPerVotePerToken[newToken] = newMinRewardPerVote;
emit UpdateRewardToken(newToken, newMinRewardPerVote);
}
// Admin functions
function _getPastDistributionAmountRollover(
QuestDataTypes.QuestRewardsType questRewardType,
uint256 periodBias,
uint256 pastRewardPerPeriod,
Quest memory _questData
) internal pure returns(uint256) {
if(periodBias == 0) return 0;
if(periodBias >= _questData.maxObjectiveVotes) return pastRewardPerPeriod;
if(questRewardType == QuestDataTypes.QuestRewardsType.FIXED) {
uint256 pastMinRewardPerVote = (pastRewardPerPeriod * UNIT) / _questData.maxObjectiveVotes;
return (periodBias * pastMinRewardPerVote) / UNIT;
} else { // For QuestDataTypes.QuestRewardsType.RANGE
// If the bias is under the minimum objective, use max reward/vote
uint256 pastMaxRewardPerVote = (pastRewardPerPeriod * UNIT) / _questData.minObjectiveVotes;
if(periodBias <= _questData.minObjectiveVotes) return (periodBias * pastMaxRewardPerVote) / UNIT;
else return pastRewardPerPeriod;
}
}
/**
* @notice Approves a new address as manager
* @dev Approves a new address as manager
* @param period Timestamp fo the period to fix
* @param questID ID of the Quest
* @param correctReducedBias Currect bias to be used for the Quest period
*/
function fixQuestPeriodBias(uint256 period, uint256 questID, uint256 correctReducedBias) external nonReentrant isAlive onlyOwner {
period = (period / WEEK) * WEEK;
if(questID >= nextID) revert Errors.InvalidQuestID();
if(distributor == address(0)) revert Errors.NoDistributorSet();
if(period == 0) revert Errors.InvalidPeriod();
if(period > getCurrentPeriod()) revert Errors.InvalidPeriod();
Quest storage _quest = quests[questID];
// This also allows to check if the given period is correct => If not, the currentState is never set to CLOSED for the QuestPeriod
if(periodStateByQuest[questID][period] != QuestDataTypes.PeriodState.CLOSED) revert Errors.PeriodNotClosed();
uint256 previousRewardAmountDistributed = periodAmountDistributedByQuest[questID][period];
uint256 previousWithdrawableAmount = _quest.rewardAmountPerPeriod - previousRewardAmountDistributed;
address questDistributor = questDistributors[questID];
if(correctReducedBias == 0 && _quest.types.closeType != QuestDataTypes.QuestCloseType.ROLLOVER) {
// Set rewardAmountDistributed back to 0, get all rewards token back to the Board
periodAmountDistributedByQuest[questID][period] = 0;
if(!MultiMerkleDistributor(questDistributor).fixQuestPeriod(questID, period, 0)) revert Errors.DisitributorFail();
if(_quest.types.closeType == QuestDataTypes.QuestCloseType.NORMAL) {
questWithdrawableAmount[questID] = questWithdrawableAmount[questID] + _quest.rewardAmountPerPeriod - previousWithdrawableAmount;
} else {
_handleUndistributedRewards(questID, period, _quest.types.closeType, previousRewardAmountDistributed);
}
}
else{
uint256 newToDistributeAmount = _getDistributionAmount(_quest.types.rewardsType, correctReducedBias, _quest);
if(_quest.types.closeType == QuestDataTypes.QuestCloseType.ROLLOVER) {
// Re-calculate the distribution amount for the period based on past data
uint256 pastRewardPerPeriod = usedRewardPerPeriod[questID][period];
newToDistributeAmount = _getPastDistributionAmountRollover(_quest.types.rewardsType, correctReducedBias, pastRewardPerPeriod, _quest);
// We simply recalculate the next period reward/vote based on the current Objective
uint256 nextPeriod = period + WEEK;
uint256 lastPeriod = questPeriods[questID][questPeriods[questID].length - 1];
// If not the last period
if(nextPeriod > lastPeriod) {
// This the Quest last period, no period to rollover to
questWithdrawableAmount[questID] = questWithdrawableAmount[questID] + (pastRewardPerPeriod - newToDistributeAmount) - previousWithdrawableAmount;
} else {
uint256 newRewardPerPeriod = newToDistributeAmount > previousRewardAmountDistributed ?
_quest.rewardAmountPerPeriod - ((newToDistributeAmount - previousRewardAmountDistributed)) :
_quest.rewardAmountPerPeriod + ((previousRewardAmountDistributed - newToDistributeAmount));
uint256 newMinRewardPerVote = (newRewardPerPeriod * UNIT) / _quest.maxObjectiveVotes;
uint256 newMaxRewardPerVote = (newRewardPerPeriod * UNIT) / _quest.minObjectiveVotes;
_quest.minRewardPerVote = newMinRewardPerVote;
_quest.maxRewardPerVote = newMaxRewardPerVote;
_quest.rewardAmountPerPeriod = newRewardPerPeriod;
emit RewardsRollover(questID, newRewardPerPeriod, newMinRewardPerVote, newMaxRewardPerVote);
}
if(newToDistributeAmount > previousRewardAmountDistributed){
uint256 missingAmount = newToDistributeAmount - previousRewardAmountDistributed;
IERC20(_quest.rewardToken).safeTransfer(questDistributor, missingAmount);
}
} else { // For QuestDataTypes.QuestCloseType.NORMAL
questWithdrawableAmount[questID] = questWithdrawableAmount[questID] + (_quest.rewardAmountPerPeriod - newToDistributeAmount) - previousWithdrawableAmount;
if(newToDistributeAmount > previousRewardAmountDistributed){
uint256 missingAmount = newToDistributeAmount - previousRewardAmountDistributed;
IERC20(_quest.rewardToken).safeTransfer(questDistributor, missingAmount);
}
}
// Fix the Period in the Distributor, and retrieve token in case too much was sent
if(!MultiMerkleDistributor(questDistributor).fixQuestPeriod(questID, period, newToDistributeAmount)) revert Errors.DisitributorFail();
periodAmountDistributedByQuest[questID][period] = newToDistributeAmount;
}
emit PeriodBiasFixed(period, questID, correctReducedBias);
}
/**
* @notice Approves a new address as manager
* @dev Approves a new address as manager
* @param newManager Address to add
*/
function approveManager(address newManager) external onlyOwner {
if(newManager == address(0)) revert Errors.AddressZero();
approvedManagers[newManager] = true;
emit ApprovedManager(newManager);
}
/**
* @notice Removes an address from the managers
* @dev Removes an address from the managers
* @param manager Address to remove
*/
function removeManager(address manager) external onlyOwner {
if(manager == address(0)) revert Errors.AddressZero();
approvedManagers[manager] = false;
emit RemovedManager(manager);
}
/**
* @notice Updates the Chest address
* @dev Updates the Chest address
* @param chest Address of the new Chest
*/
function updateChest(address chest) external onlyOwner {
if(chest == address(0)) revert Errors.AddressZero();
address oldChest = questChest;
questChest = chest;
emit ChestUpdated(oldChest, chest);
}
/**
* @notice Updates the Distributor address
* @dev Updates the Distributor address
* @param newDistributor Address of the new Distributor
*/
function updateDistributor(address newDistributor) external onlyOwner {
if(newDistributor == address(0)) revert Errors.AddressZero();
address oldDistributor = distributor;
distributor = newDistributor;
emit DistributorUpdated(oldDistributor, distributor);
}
/**
* @notice Updates the Platfrom fees BPS ratio
* @dev Updates the Platfrom fees BPS ratio
* @param newFee New fee ratio
*/
function updatePlatformFee(uint256 newFee) external onlyOwner {
if(newFee > 500) revert Errors.InvalidParameter();
uint256 oldfee = platformFeeRatio;
platformFeeRatio = newFee;
emit PlatformFeeRatioUpdated(oldfee, newFee);
}
/**
* @notice Updates the min objective value
* @dev Updates the min objective value
* @param newMinObjective New min objective
*/
function updateMinObjective(uint256 newMinObjective) external onlyOwner {
if(newMinObjective == 0) revert Errors.InvalidParameter();
uint256 oldMinObjective = objectiveMinimalThreshold;
objectiveMinimalThreshold = newMinObjective;
emit MinObjectiveUpdated(oldMinObjective, newMinObjective);
}
/**
* @notice Sets a custom fee ratio for a given address
* @dev Sets a custom fee ratio for a given address
* @param user User address
* @param customFeeRatio Custom fee ratio
*/
function setCustomFeeRatio(address user, uint256 customFeeRatio) external onlyOwner {
if(customFeeRatio > platformFeeRatio) revert Errors.InvalidParameter();
customPlatformFeeRatio[user] = customFeeRatio;
emit SetCustomFeeRatio(user, customFeeRatio);
}
/**
* @notice Recovers ERC2O tokens sent by mistake to the contract
* @dev Recovers ERC2O tokens sent by mistake to the contract
* @param token Address tof the EC2O token
* @return bool: success
*/
function recoverERC20(address token) external onlyOwner returns(bool) {
if(whitelistedTokens[token]) revert Errors.CannotRecoverToken();
uint256 amount = IERC20(token).balanceOf(address(this));
if(amount == 0) revert Errors.NullAmount();
IERC20(token).safeTransfer(owner(), amount);
return true;
}
/**
* @notice Kills the contract
* @dev Kills the contract
*/
function killBoard() external onlyOwner {
if(isKilled) revert Errors.AlreadyKilled();
isKilled = true;
killTs = block.timestamp;
emit Killed(killTs);
}
/**
* @notice Unkills the contract
* @dev Unkills the contract
*/
function unkillBoard() external onlyOwner {
if(!isKilled) revert Errors.NotKilled();
if(block.timestamp >= killTs + KILL_DELAY) revert Errors.KillDelayExpired();
isKilled = false;
emit Unkilled(block.timestamp);
}
// Utils
function safe48(uint n) internal pure returns (uint48) {
if(n > type(uint48).max) revert Errors.NumberExceed48Bits();
return uint48(n);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import "../oz/utils/Ownable.sol";
import "../libraries/Errors.sol";
/** @title 2-step Ownership */
/// @author Paladin
/*
Extends OZ Ownable contract to add 2-step ownership transfer
*/
contract Owner is Ownable {
address public pendingOwner;
event NewPendingOwner(address indexed previousPendingOwner, address indexed newPendingOwner);
function transferOwnership(address newOwner) public override virtual onlyOwner {
if(newOwner == address(0)) revert Errors.AddressZero();
if(newOwner == owner()) revert Errors.CannotBeOwner();
address oldPendingOwner = pendingOwner;
pendingOwner = newOwner;
emit NewPendingOwner(oldPendingOwner, newOwner);
}
function acceptOwnership() public virtual {
if(msg.sender != pendingOwner) revert Errors.CallerNotPendingOwner();
address newOwner = pendingOwner;
_transferOwnership(pendingOwner);
pendingOwner = address(0);
emit NewPendingOwner(newOwner, address(0));
}
}