Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Liquid staking tests #203

Merged
merged 21 commits into from Aug 17, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
}
}
107 changes: 107 additions & 0 deletions contracts/governance/staking/GamifiedManager.sol
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity 0.8.6;

import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import { Balance, Checkpoint, Quest, QuestType, QuestStatus } from "./GamifiedTokenStructs.sol";
// import { SignatureVerifier } from "./deps/SignatureVerifier.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(_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);
}

/**
* @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();
}
}
108 changes: 36 additions & 72 deletions contracts/governance/staking/GamifiedToken.sol
@@ -1,13 +1,14 @@
// 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 "./GamifiedTokenStructs.sol";
import "./GamifiedManager.sol";
alsco77 marked this conversation as resolved.
Show resolved Hide resolved

/**
* @title GamifiedToken
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,7 @@ 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 @@ -479,11 +441,12 @@ 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,32 +464,33 @@ 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);
}

// 2. Change the cooldown percentage based on size of recent withdrawal
_balances[_account].cooldownMultiplier = SafeCast.toUint16(_cooldownPercentage / 1e16);
_balances[_account].cooldownMultiplier = SafeCast.toUint16(_cooldownPercentage / 1e18);
alsco77 marked this conversation as resolved.
Show resolved Hide resolved

// 3. Set back scaled time
// e.g. stake 10 for 100 seconds, withdraw 5.
// secondsHeld = (100 - 0) * (10 - 1.25) = 875
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;
alsco77 marked this conversation as resolved.
Show resolved Hide resolved

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
19 changes: 19 additions & 0 deletions contracts/governance/staking/GamifiedTokenStructs.sol
Expand Up @@ -38,3 +38,22 @@ struct Quest {
/// Expiry date in seconds for the quest
uint32 expiry;
}

struct SafetyData {
alsco77 marked this conversation as resolved.
Show resolved Hide resolved
/// Percentage of collateralisation where 100% = 1e18
uint128 collateralisationRatio;
/// Slash % where 100% = 1e18
uint128 slashingPercentage;
}

struct CooldownData {
/// Time at which the relative cooldown began
uint128 timestamp;
/// Percentage of a users funds up for cooldown where 100% = 1e18
uint128 percentage;
}

struct Checkpoint {
uint32 fromBlock;
uint224 votes;
}
5 changes: 1 addition & 4 deletions contracts/governance/staking/GamifiedVotingToken.sol
Expand Up @@ -7,6 +7,7 @@ import { ECDSAUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/cryp
import { GamifiedToken } from "./GamifiedToken.sol";
import { IGovernanceHook } from "./interfaces/IGovernanceHook.sol";
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { Checkpoint } from "./GamifiedTokenStructs.sol";

/**
* @title GamifiedVotingToken
Expand All @@ -23,10 +24,6 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I
* - Add _governanceHook hook
*/
abstract contract GamifiedVotingToken is Initializable, GamifiedToken {
struct Checkpoint {
uint32 fromBlock;
uint224 votes;
}

mapping(address => address) private _delegates;
mapping(address => Checkpoint[]) private _checkpoints;
Expand Down