Skip to content

Commit

Permalink
Merge b0ce37f into d8502b6
Browse files Browse the repository at this point in the history
  • Loading branch information
alsco77 committed Aug 17, 2021
2 parents d8502b6 + b0ce37f commit 6e5f86d
Show file tree
Hide file tree
Showing 43 changed files with 3,905 additions and 791 deletions.
4 changes: 3 additions & 1 deletion .solhint.json
Expand Up @@ -5,6 +5,8 @@
"avoid-suicide": "error",
"avoid-sha3": "warn",
"compiler-version": ["warn", "^0.8.6"],
"func-visibility": ["warn", { "ignoreConstructors": true }]
"func-visibility": ["warn", { "ignoreConstructors": true }],
"not-rely-on-time": "off",
"no-empty-blocks": "off"
}
}
105 changes: 105 additions & 0 deletions contracts/governance/staking/GamifiedManager.sol
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.6;

import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "./GamifiedTokenStructs.sol";

/**
* @title GamifiedManager
* @author mStable
* @notice library to reduce the size of the GamifiedToken contract.
* @dev VERSION: 1.0
* DATE: 2021-08-11
*/
library GamifiedManager {
event QuestAdded(
address questMaster,
uint256 id,
QuestType model,
uint16 multiplier,
QuestStatus status,
uint32 expiry
);
event QuestComplete(address indexed user, uint256 indexed id);
event QuestExpired(uint16 indexed id);
event QuestSeasonEnded();

/***************************************
QUESTS
****************************************/

/**
* @dev Called by questMasters to add a new quest to the system with default 'ACTIVE' status
* @param _model Type of quest rewards multiplier (does it last forever or just for the season).
* @param _multiplier Multiplier, from 1 == 1.01x to 100 == 2.00x
* @param _expiry Timestamp at which quest expires. Note that permanent quests should still be given a timestamp.
*/
function addQuest(
Quest[] storage _quests,
QuestType _model,
uint16 _multiplier,
uint32 _expiry
) external {
require(_expiry > block.timestamp + 1 days, "Quest window too small");
require(_multiplier > 0 && _multiplier <= 50, "Quest multiplier too large > 1.5x");

_quests.push(
Quest({
model: _model,
multiplier: _multiplier,
status: QuestStatus.ACTIVE,
expiry: _expiry
})
);

emit QuestAdded(
msg.sender,
_quests.length - 1,
_model,
_multiplier,
QuestStatus.ACTIVE,
_expiry
);
}

/**
* @dev Called by questMasters to expire a quest, setting it's status as EXPIRED. After which it can
* no longer be completed.
* @param _id Quest ID (its position in the array)
*/
function expireQuest(Quest[] storage _quests, uint16 _id) external {
require(_id < _quests.length, "Quest does not exist");
require(_quests[_id].status == QuestStatus.ACTIVE, "Quest already expired");

_quests[_id].status = QuestStatus.EXPIRED;
if (block.timestamp < _quests[_id].expiry) {
_quests[_id].expiry = SafeCast.toUint32(block.timestamp);
}

emit QuestExpired(_id);
}

/**
* @dev Called by questMasters to start a new quest season. After this, all current
* seasonMultipliers will be reduced at the next user action (or triggered manually).
* In order to reduce cost for any keepers, it is suggested to add quests at the start
* of a new season to incentivise user actions.
* A new season can only begin after 9 months has passed.
*/
function startNewQuestSeason(uint32 seasonEpoch, Quest[] storage _quests) external {
require(block.timestamp > (seasonEpoch + 39 weeks), "Season has not elapsed");

uint256 len = _quests.length;
for (uint256 i = 0; i < len; i++) {
Quest memory quest = _quests[i];
if (quest.model == QuestType.SEASONAL) {
require(
quest.status == QuestStatus.EXPIRED || block.timestamp > quest.expiry,
"All seasonal quests must have expired"
);
}
}

emit QuestSeasonEnded();
}
}
118 changes: 43 additions & 75 deletions contracts/governance/staking/GamifiedToken.sol
@@ -1,12 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.6;

import { ILockedERC20 } from "./interfaces/ILockedERC20.sol";
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { HeadlessStakingRewards } from "../../rewards/staking/HeadlessStakingRewards.sol";
import { ContextUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
import { SignatureVerifier } from "./deps/SignatureVerifier.sol";
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import { ILockedERC20 } from "./interfaces/ILockedERC20.sol";
import { SignatureVerifier } from "./deps/SignatureVerifier.sol";
import { HeadlessStakingRewards } from "../../rewards/staking/HeadlessStakingRewards.sol";
import { GamifiedManager } from "./GamifiedManager.sol";
import "./GamifiedTokenStructs.sol";

/**
Expand All @@ -27,9 +28,10 @@ abstract contract GamifiedToken is
ILockedERC20,
Initializable,
ContextUpgradeable,
SignatureVerifier,
HeadlessStakingRewards
{
/// @notice address that signs user quests have been completed
address public immutable _signer;
/// @notice name of this token (ERC20)
string public override name;
/// @notice symbol of this token (ERC20)
Expand Down Expand Up @@ -66,15 +68,17 @@ abstract contract GamifiedToken is
****************************************/

/**
* @param _signer Signer address is used to verify completion of quests off chain
* @param _signerArg Signer address is used to verify completion of quests off chain
* @param _nexus System nexus
* @param _rewardsToken Token that is being distributed as a reward. eg MTA
*/
constructor(
address _signer,
address _signerArg,
address _nexus,
address _rewardsToken
) SignatureVerifier(_signer) HeadlessStakingRewards(_nexus, _rewardsToken) {}
) HeadlessStakingRewards(_nexus, _rewardsToken) {
_signer = _signerArg;
}

/**
* @param _nameArg Token name
Expand All @@ -97,10 +101,14 @@ abstract contract GamifiedToken is
* @dev Checks that _msgSender is either governor or the quest master
*/
modifier questMasterOrGovernor() {
require(_msgSender() == _questMaster || _msgSender() == _governor(), "Not verified");
_questMasterOrGovernor();
_;
}

function _questMasterOrGovernor() internal view {
require(_msgSender() == _questMaster || _msgSender() == _governor(), "Not verified");
}

/***************************************
VIEWS
****************************************/
Expand Down Expand Up @@ -157,26 +165,19 @@ abstract contract GamifiedToken is
}

/**
* @dev Raw balance data
* @notice Raw staked balance without any multipliers
*/
function balanceData(address _account) external view returns (Balance memory) {
return _balances[_account];
}

/**
* @dev Gets raw quest data
* @notice Gets raw quest data
*/
function getQuest(uint256 _id) external view returns (Quest memory) {
return _quests[_id];
}

/**
* @dev Gets a users quest completion status
*/
function getQuestCompletion(address _account, uint256 _id) external view returns (bool) {
return _questCompletion[_account][_id];
}

/***************************************
QUESTS
****************************************/
Expand All @@ -192,26 +193,7 @@ abstract contract GamifiedToken is
uint16 _multiplier,
uint32 _expiry
) external questMasterOrGovernor {
require(_expiry > block.timestamp + 1 days, "Quest window too small");
require(_multiplier > 0 && _multiplier <= 50, "Quest multiplier too large > 1.5x");

_quests.push(
Quest({
model: _model,
multiplier: _multiplier,
status: QuestStatus.ACTIVE,
expiry: _expiry
})
);

emit QuestAdded(
_msgSender(),
_quests.length - 1,
_model,
_multiplier,
QuestStatus.ACTIVE,
_expiry
);
GamifiedManager.addQuest(_quests, _model, _multiplier, _expiry);
}

/**
Expand All @@ -220,15 +202,7 @@ abstract contract GamifiedToken is
* @param _id Quest ID (its position in the array)
*/
function expireQuest(uint16 _id) external questMasterOrGovernor {
require(_quests.length >= _id, "Quest does not exist");
require(_quests[_id].status == QuestStatus.ACTIVE, "Quest already expired");

_quests[_id].status = QuestStatus.EXPIRED;
if (block.timestamp < _quests[_id].expiry) {
_quests[_id].expiry = SafeCast.toUint32(block.timestamp);
}

emit QuestExpired(_id);
GamifiedManager.expireQuest(_quests, _id);
}

/**
Expand All @@ -239,22 +213,10 @@ abstract contract GamifiedToken is
* A new season can only begin after 9 months has passed.
*/
function startNewQuestSeason() external questMasterOrGovernor {
require(block.timestamp > (seasonEpoch + 39 weeks), "Season has not elapsed");

uint256 len = _quests.length;
for (uint256 i = 0; i < len; i++) {
Quest memory quest = _quests[i];
if (quest.model == QuestType.SEASONAL) {
require(
quest.status == QuestStatus.EXPIRED || block.timestamp > quest.expiry,
"All seasonal quests must have expired"
);
}
}
GamifiedManager.startNewQuestSeason(seasonEpoch, _quests);

// Have to set storage variable here as it can't be done in the library function
seasonEpoch = SafeCast.toUint32(block.timestamp);

emit QuestSeasonEnded();
}

/**
Expand All @@ -276,7 +238,10 @@ abstract contract GamifiedToken is
for (uint256 i = 0; i < len; i++) {
require(_validQuest(_ids[i]), "Err: Invalid Quest");
require(!hasCompleted(_account, _ids[i]), "Err: Already Completed");
require(verify(_account, _ids[i], _signatures[i]), "Err: Invalid Signature");
require(
SignatureVerifier.verify(_signer, _account, _ids[i], _signatures[i]),
"Err: Invalid Signature"
);

// store user quest has completed
_questCompletion[_account][_ids[i]] = true;
Expand Down Expand Up @@ -304,7 +269,7 @@ abstract contract GamifiedToken is
*/
function _validQuest(uint256 _id) internal view returns (bool) {
return
_quests.length >= _id &&
_id < _quests.length &&
_quests[_id].status == QuestStatus.ACTIVE &&
block.timestamp < _quests[_id].expiry;
}
Expand Down Expand Up @@ -454,12 +419,12 @@ abstract contract GamifiedToken is
* Importantly, when a user stakes more, their weightedTimestamp is reduced proportionate to their stake.
* @param _account Address of user to credit
* @param _rawAmount Raw amount of tokens staked
* @param _exitCooldown Reset the users cooldown slash
* @param _newPercentage Set the users cooldown slash
*/
function _mintRaw(
address _account,
uint256 _rawAmount,
bool _exitCooldown
uint256 _newPercentage
) internal virtual updateReward(_account) {
require(_account != address(0), "ERC20: mint to the zero address");

Expand All @@ -468,7 +433,7 @@ abstract contract GamifiedToken is
_balances[_account].raw = oldBalance.raw + SafeCast.toUint128(_rawAmount);

// 2. Exit cooldown if necessary
if (_exitCooldown) _balances[_account].cooldownMultiplier = 0;
_balances[_account].cooldownMultiplier = SafeCast.toUint16(_newPercentage / 1e16);

// 3. Set weighted timestamp
// i) For new _account, set up weighted timestamp
Expand All @@ -479,11 +444,13 @@ abstract contract GamifiedToken is
}
// ii) For previous minters, recalculate time held
// Calc new weighted timestamp
uint256 secondsHeld = (block.timestamp - oldBalance.weightedTimestamp) * oldBalance.raw;
uint256 newWeightedTs = secondsHeld / (oldBalance.raw + (_rawAmount / 2));
_balances[_account].weightedTimestamp = SafeCast.toUint32(block.timestamp - newWeightedTs);
uint256 oldWeighredSecondsHeld = (block.timestamp - oldBalance.weightedTimestamp) *
oldBalance.raw;
uint256 newSecondsHeld = oldWeighredSecondsHeld / (oldBalance.raw + (_rawAmount / 2));
uint32 newWeightedTs = SafeCast.toUint32(block.timestamp - newSecondsHeld);
_balances[_account].weightedTimestamp = newWeightedTs;

uint16 timeMultiplier = _timeMultiplier(SafeCast.toUint32(newWeightedTs));
uint16 timeMultiplier = _timeMultiplier(newWeightedTs);
_balances[_account].timeMultiplier = timeMultiplier;

// 3. Update scaled balance
Expand All @@ -501,11 +468,11 @@ abstract contract GamifiedToken is
uint256 _rawAmount,
uint128 _cooldownPercentage
) internal virtual updateReward(_account) {
require(_account != address(0), "ERC20: burn from the zero address");
require(_account != address(0), "ERC20: burn from zero address");

// 1. Get and update current balance
(Balance memory oldBalance, uint256 oldScaledBalance) = _prepareOldBalance(_account);
require(oldBalance.raw >= _rawAmount, "ERC20: burn amount exceeds balance");
require(oldBalance.raw >= _rawAmount, "ERC20: burn amount > balance");
unchecked {
_balances[_account].raw = oldBalance.raw - SafeCast.toUint128(_rawAmount);
}
Expand All @@ -519,14 +486,15 @@ abstract contract GamifiedToken is
uint256 secondsHeld = (block.timestamp - oldBalance.weightedTimestamp) *
(oldBalance.raw - (_rawAmount / 4));
// newWeightedTs = 875 / 100 = 87.5
uint256 newWeightedTs = secondsHeld / oldBalance.raw;
_balances[_account].weightedTimestamp = SafeCast.toUint32(block.timestamp - newWeightedTs);
uint256 newSecondsHeld = secondsHeld / oldBalance.raw;
uint32 newWeightedTs = SafeCast.toUint32(block.timestamp - newSecondsHeld);
_balances[_account].weightedTimestamp = newWeightedTs;

uint16 timeMultiplier = _timeMultiplier(SafeCast.toUint32(newWeightedTs));
uint16 timeMultiplier = _timeMultiplier(newWeightedTs);
_balances[_account].timeMultiplier = timeMultiplier;

// 4. Update scaled balance
_burnScaled(_account, oldScaledBalance - _getBalance(_balances[_account]));
_settleScaledBalance(_account, oldScaledBalance);
}

/***************************************
Expand Down
3 changes: 3 additions & 0 deletions contracts/governance/staking/GamifiedTokenStructs.sol
Expand Up @@ -18,16 +18,19 @@ struct Balance {
/// shows if a user has entered their cooldown ready for a withdrawal. Can be used to slash voting balance
uint16 cooldownMultiplier;
}

/// @notice Quests can either give permanent rewards or only for the season
enum QuestType {
PERMANENT,
SEASONAL
}

/// @notice Quests can be turned off by the questMaster. All those who already completed remain
enum QuestStatus {
ACTIVE,
EXPIRED
}

struct Quest {
/// Type of quest rewards
QuestType model;
Expand Down

0 comments on commit 6e5f86d

Please sign in to comment.