diff --git a/contracts/governance/staking/GamifiedToken.sol b/contracts/governance/staking/GamifiedToken.sol index 031b0af9..42cbb676 100644 --- a/contracts/governance/staking/GamifiedToken.sol +++ b/contracts/governance/staking/GamifiedToken.sol @@ -30,8 +30,6 @@ abstract contract GamifiedToken is ContextUpgradeable, 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) @@ -48,8 +46,8 @@ abstract contract GamifiedToken is /// @notice Timestamp at which the current season started uint32 public seasonEpoch; - /// @notice A whitelisted questMaster who can add quests - address internal _questMaster; + /// @notice A whitelisted questMaster who can administer quests including signing user quests are completed. + address public questMaster; /// @notice Tracks the cooldowns for all users mapping(address => CooldownData) public stakersCooldowns; @@ -65,38 +63,37 @@ abstract contract GamifiedToken is event QuestComplete(address indexed user, uint256 indexed id); event QuestExpired(uint16 indexed id); event QuestSeasonEnded(); + event QuestMaster(address oldQuestMaster, address newQuestMaster); /*************************************** INIT ****************************************/ /** - * @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 _signerArg, - address _nexus, - address _rewardsToken - ) HeadlessStakingRewards(_nexus, _rewardsToken) { - _signer = _signerArg; - } + constructor(address _nexus, address _rewardsToken) + HeadlessStakingRewards(_nexus, _rewardsToken) + {} /** * @param _nameArg Token name * @param _symbolArg Token symbol * @param _rewardsDistributorArg mStable Rewards Distributor + * @param _questMaster account that can sign user quests as completed */ function __GamifiedToken_init( string memory _nameArg, string memory _symbolArg, - address _rewardsDistributorArg + address _rewardsDistributorArg, + address _questMaster ) internal initializer { __Context_init_unchained(); name = _nameArg; symbol = _symbolArg; seasonEpoch = SafeCast.toUint32(block.timestamp); + questMaster = _questMaster; HeadlessStakingRewards._initialize(_rewardsDistributorArg); } @@ -109,7 +106,20 @@ abstract contract GamifiedToken is } function _questMasterOrGovernor() internal view { - require(_msgSender() == _questMaster || _msgSender() == _governor(), "Not verified"); + require(_msgSender() == questMaster || _msgSender() == _governor(), "Not verified"); + } + + /*************************************** + Admin + ****************************************/ + + /** + * @dev Sets the quest master that can sign user quests as being completed + */ + function setQuestMaster(address _newQuestMaster) external questMasterOrGovernor() { + emit QuestMaster(questMaster, _newQuestMaster); + + questMaster = _newQuestMaster; } /*************************************** @@ -237,7 +247,7 @@ abstract contract GamifiedToken is require(_validQuest(_ids[i]), "Err: Invalid Quest"); require(!hasCompleted(_account, _ids[i]), "Err: Already Completed"); require( - SignatureVerifier.verify(_signer, _account, _ids[i], _signatures[i]), + SignatureVerifier.verify(questMaster, _account, _ids[i], _signatures[i]), "Err: Invalid Signature" ); @@ -476,11 +486,13 @@ abstract contract GamifiedToken is * @param _account Address of user * @param _rawAmount Raw amount of tokens to remove * @param _exitCooldown Exit the cooldown? + * @param _finalise Has recollateralisation happened? If so, everything is cooled down */ function _burnRaw( address _account, uint256 _rawAmount, - bool _exitCooldown + bool _exitCooldown, + bool _finalise ) internal virtual updateReward(_account) { require(_account != address(0), "ERC20: burn from zero address"); @@ -488,7 +500,13 @@ abstract contract GamifiedToken is (Balance memory oldBalance, uint256 oldScaledBalance) = _prepareOldBalance(_account); CooldownData memory cooldownData = stakersCooldowns[_msgSender()]; uint256 totalRaw = oldBalance.raw + cooldownData.units; - // 1.1 Update + // 1.1. If _finalise, move everything to cooldown + if (_finalise) { + _balances[_account].raw = 0; + stakersCooldowns[_account].units = SafeCast.toUint128(totalRaw); + cooldownData.units = SafeCast.toUint128(totalRaw); + } + // 1.2. Update require(cooldownData.units >= _rawAmount, "ERC20: burn amount > balance"); unchecked { stakersCooldowns[_account].units -= SafeCast.toUint128(_rawAmount); diff --git a/contracts/governance/staking/GamifiedVotingToken.sol b/contracts/governance/staking/GamifiedVotingToken.sol index 8c288fba..ac68e3fe 100644 --- a/contracts/governance/staking/GamifiedVotingToken.sol +++ b/contracts/governance/staking/GamifiedVotingToken.sol @@ -55,10 +55,9 @@ abstract contract GamifiedVotingToken is Initializable, GamifiedToken { ); constructor( - address _signer, address _nexus, address _rewardsToken - ) GamifiedToken(_signer, _nexus, _rewardsToken) {} + ) GamifiedToken(_nexus, _rewardsToken) {} function __GamifiedVotingToken_init() internal initializer {} diff --git a/contracts/governance/staking/StakedToken.sol b/contracts/governance/staking/StakedToken.sol index 1d42c046..d6cb752f 100644 --- a/contracts/governance/staking/StakedToken.sol +++ b/contracts/governance/staking/StakedToken.sol @@ -2,8 +2,6 @@ pragma solidity 0.8.6; pragma abicoder v2; -import "hardhat/console.sol"; - import { IStakedToken } from "./interfaces/IStakedToken.sol"; import { GamifiedVotingToken } from "./GamifiedVotingToken.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -66,7 +64,6 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { ****************************************/ /** - * @param _signer 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 * @param _stakedToken Core token that is staked and tracked (e.g. MTA) @@ -74,13 +71,12 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { * @param _unstakeWindow Window in which it is possible to withdraw, following the cooldown period */ constructor( - address _signer, address _nexus, address _rewardsToken, address _stakedToken, uint256 _cooldownSeconds, uint256 _unstakeWindow - ) GamifiedVotingToken(_signer, _nexus, _rewardsToken) { + ) GamifiedVotingToken(_nexus, _rewardsToken) { STAKED_TOKEN = IERC20(_stakedToken); COOLDOWN_SECONDS = _cooldownSeconds; UNSTAKE_WINDOW = _unstakeWindow; @@ -90,13 +86,15 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { * @param _nameArg Token name * @param _symbolArg Token symbol * @param _rewardsDistributorArg mStable Rewards Distributor + * @param _questMaster account that signs user quests as completed */ function initialize( string memory _nameArg, string memory _symbolArg, - address _rewardsDistributorArg + address _rewardsDistributorArg, + address _questMaster ) external initializer { - __GamifiedToken_init(_nameArg, _symbolArg, _rewardsDistributorArg); + __GamifiedToken_init(_nameArg, _symbolArg, _rewardsDistributorArg, _questMaster); safetyData = SafetyData({ collateralisationRatio: 1e18, slashingPercentage: 0 }); } @@ -131,10 +129,7 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { function _assertNotContract() internal view { if (_msgSender() != tx.origin) { - require( - whitelistedWrappers[_msgSender()], - "Transactions from non-whitelisted smart contracts not allowed" - ); + require(whitelistedWrappers[_msgSender()], "Not a whitelisted contract"); } } @@ -257,7 +252,7 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { // Is the contract post-recollateralisation? if (safetyData.collateralisationRatio != 1e18) { // 1. If recollateralisation has occured, the contract is finished and we can skip all checks - _burnRaw(_msgSender(), _amount, false); + _burnRaw(_msgSender(), _amount, false, true); // 2. Return a proportionate amount of tokens, based on the collateralisation ratio STAKED_TOKEN.safeTransfer( _recipient, @@ -298,7 +293,7 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { bool exitCooldown = _exitCooldown || totalWithdraw == maxWithdrawal; // 5. Settle the withdrawal by burning the voting tokens - _burnRaw(_msgSender(), totalWithdraw, exitCooldown); + _burnRaw(_msgSender(), totalWithdraw, exitCooldown, false); // Log any redemption fee to the rewards contract _notifyAdditionalReward(totalWithdraw - userWithdrawal); // Finally transfer tokens back to recipient @@ -382,7 +377,6 @@ contract StakedToken is IStakedToken, GamifiedVotingToken { onlyGovernor onlyBeforeRecollateralisation { - require(safetyData.collateralisationRatio == 1e18, "Process already begun"); require(_newRate <= 5e17, "Cannot exceed 50%"); safetyData.slashingPercentage = SafeCast.toUint128(_newRate); diff --git a/contracts/governance/staking/StakedTokenBPT.sol b/contracts/governance/staking/StakedTokenBPT.sol index f5490e82..66710159 100644 --- a/contracts/governance/staking/StakedTokenBPT.sol +++ b/contracts/governance/staking/StakedTokenBPT.sol @@ -24,7 +24,6 @@ contract StakedTokenBPT is StakedToken { event BalRecipientChanged(address newRecipient); /** - * @param _signer 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 * @param _stakedToken Core token that is staked and tracked (e.g. MTA) @@ -33,14 +32,13 @@ contract StakedTokenBPT is StakedToken { * @param _bal Balancer addresses, [0] = $BAL addr, [1] = designated recipient */ constructor( - address _signer, address _nexus, address _rewardsToken, address _stakedToken, uint256 _cooldownSeconds, uint256 _unstakeWindow, address[2] memory _bal - ) StakedToken(_signer, _nexus, _rewardsToken, _stakedToken, _cooldownSeconds, _unstakeWindow) { + ) StakedToken(_nexus, _rewardsToken, _stakedToken, _cooldownSeconds, _unstakeWindow) { BAL = IERC20(_bal[0]); balRecipient = _bal[1]; } diff --git a/contracts/governance/staking/StakedTokenMTA.sol b/contracts/governance/staking/StakedTokenMTA.sol index 680fd387..7da24c43 100644 --- a/contracts/governance/staking/StakedTokenMTA.sol +++ b/contracts/governance/staking/StakedTokenMTA.sol @@ -15,7 +15,6 @@ contract StakedTokenMTA is StakedToken { using SafeERC20 for IERC20; /** - * @param _signer 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 * @param _stakedToken Core token that is staked and tracked (e.g. MTA) @@ -23,13 +22,12 @@ contract StakedTokenMTA is StakedToken { * @param _unstakeWindow Window in which it is possible to withdraw, following the cooldown period */ constructor( - address _signer, address _nexus, address _rewardsToken, address _stakedToken, uint256 _cooldownSeconds, uint256 _unstakeWindow - ) StakedToken(_signer, _nexus, _rewardsToken, _stakedToken, _cooldownSeconds, _unstakeWindow) {} + ) StakedToken(_nexus, _rewardsToken, _stakedToken, _cooldownSeconds, _unstakeWindow) {} /** * @dev Allows a staker to compound their rewards IF the Staking token and the Rewards token are the same diff --git a/contracts/governance/staking/deps/SignatureVerifier.sol b/contracts/governance/staking/deps/SignatureVerifier.sol index 303dc2c7..dbf4f222 100644 --- a/contracts/governance/staking/deps/SignatureVerifier.sol +++ b/contracts/governance/staking/deps/SignatureVerifier.sol @@ -25,7 +25,6 @@ pragma solidity ^0.8.0; library SignatureVerifier { - function verify( address signer, address account, @@ -38,16 +37,16 @@ library SignatureVerifier { return recoverSigner(ethSignedMessageHash, signature) == signer; } - function getMessageHash(address account, uint256 id) public pure returns (bytes32) { + function getMessageHash(address account, uint256 id) internal pure returns (bytes32) { return keccak256(abi.encodePacked(account, id)); } - function getEthSignedMessageHash(bytes32 messageHash) public pure returns (bytes32) { + function getEthSignedMessageHash(bytes32 messageHash) internal pure returns (bytes32) { return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)); } function recoverSigner(bytes32 _ethSignedMessageHash, bytes memory _signature) - public + internal pure returns (address) { diff --git a/contracts/rewards/staking/PlatformTokenVendorFactory.sol b/contracts/rewards/staking/PlatformTokenVendorFactory.sol index a3997c97..7c10f7a0 100644 --- a/contracts/rewards/staking/PlatformTokenVendorFactory.sol +++ b/contracts/rewards/staking/PlatformTokenVendorFactory.sol @@ -1,4 +1,3 @@ - // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity 0.8.6; @@ -17,7 +16,7 @@ library PlatformTokenVendorFactory { return true; } - /** + /** * @notice Deploys a new PlatformTokenVendor contract * @param _rewardsToken reward or platform rewards token. eg MTA or WMATIC * @return address of the deployed PlatformTokenVendor contract diff --git a/contracts/shared/ImmutableModule.sol b/contracts/shared/ImmutableModule.sol index f03b50bd..1b408aca 100644 --- a/contracts/shared/ImmutableModule.sol +++ b/contracts/shared/ImmutableModule.sol @@ -79,8 +79,8 @@ abstract contract ImmutableModule is ModuleKeys { } /** - * @dev Return Recollateraliser Module address from the Nexus - * @return Address of the Recollateraliser Module contract (Phase 2) + * @dev Return Liquidator Module address from the Nexus + * @return Address of the Liquidator Module contract */ function _liquidator() internal view returns (address) { return nexus.getModule(KEY_LIQUIDATOR); diff --git a/contracts/z_mocks/governance/stakedTokenWrapper.sol b/contracts/z_mocks/governance/stakedTokenWrapper.sol new file mode 100644 index 00000000..c1da402b --- /dev/null +++ b/contracts/z_mocks/governance/stakedTokenWrapper.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.6; + +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { StakedToken } from "../../governance/staking/StakedToken.sol"; + +/** + * Used to test contract interactions with the StakedToken + */ +contract StakedTokenWrapper { + using SafeERC20 for IERC20; + + IERC20 public rewardsToken; + StakedToken public stakedToken; + + constructor(address _rewardsToken, address _stakedToken) { + stakedToken = StakedToken(_stakedToken); + rewardsToken = IERC20(_rewardsToken); + rewardsToken.safeApprove(_stakedToken, 2**256 - 1); + } + + function stake(uint256 _amount) external { + stakedToken.stake(_amount); + } + + function stake(uint256 _amount, address _delegatee) external { + stakedToken.stake(_amount, _delegatee); + } + + function withdraw( + uint256 _amount, + address _recipient, + bool _amountIncludesFee, + bool _exitCooldown + ) external { + stakedToken.withdraw(_amount, _recipient, _amountIncludesFee, _exitCooldown); + } +} diff --git a/contracts/z_mocks/nexus/MockNexus.sol b/contracts/z_mocks/nexus/MockNexus.sol index 533b90b8..54182e9f 100644 --- a/contracts/z_mocks/nexus/MockNexus.sol +++ b/contracts/z_mocks/nexus/MockNexus.sol @@ -39,4 +39,8 @@ contract MockNexus is ModuleKeys { function setLiquidator(address _liquidator) external { modules[KEY_LIQUIDATOR] = _liquidator; } + + function setRecollateraliser(address _recollateraliser) external { + modules[KEY_RECOLLATERALISER] = _recollateraliser; + } } diff --git a/package.json b/package.json index 2bd3edbc..2dd1f4f3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "scripts": { "prettify": "", "lint": "yarn run lint-ts; yarn run lint-sol", - "lint:fix": "yarn pretty-quick --pattern '**/*.*(sol|json)' --staged --verbose && yarn prettier --write tasks/**/*.ts test/**/*.ts types/*.ts", + "lint:fix": "yarn pretty-quick --pattern '**/*.*(sol|json)' --staged --verbose && yarn pretty-quick --staged --write tasks/**/*.ts test/**/*.ts types/*.ts", "lint-ts": "yarn eslint ./test --ext .ts --fix --quiet", "lint-sol": "solhint 'contracts/**/*.sol'", "compile-abis": "typechain --target=ethers-v5 --out-dir types/generated \"?(contracts|build)/!(build-info)/**/+([a-zA-Z0-9]).json\"", diff --git a/tasks/deployBoostedVault.ts b/tasks/deployBoostedVault.ts index c80e5c69..514698b4 100644 --- a/tasks/deployBoostedVault.ts +++ b/tasks/deployBoostedVault.ts @@ -127,7 +127,6 @@ task("StakedToken.deploy", "Deploys a Staked Token behind a proxy") const stakedTokenFactory = new StakedToken__factory(stakedTokenLibraryAddresses, deployer.signer) console.log(`Staked contract size ${StakedToken__factory.bytecode.length / 2} bytes`) const stakedTokenImpl = await deployContract(stakedTokenFactory, "StakedToken", [ - taskArgs.questSignerAddress, nexusAddress, rewardsTokenAddress, rewardsTokenAddress, @@ -135,7 +134,7 @@ task("StakedToken.deploy", "Deploys a Staked Token behind a proxy") ONE_DAY.mul(2), ]) - const data = stakedTokenImpl.interface.encodeFunctionData("initialize", [taskArgs.name, taskArgs.symbol, rewardsDistributorAddress]) + const data = stakedTokenImpl.interface.encodeFunctionData("initialize", [taskArgs.name, taskArgs.symbol, rewardsDistributorAddress, taskArgs.questSignerAddress]) await deployContract(new AssetProxy__factory(deployer.signer), "AssetProxy", [stakedTokenImpl.address, deployer.address, data]) }) diff --git a/tasks/rewards.ts b/tasks/rewards.ts index a568d380..52af5916 100644 --- a/tasks/rewards.ts +++ b/tasks/rewards.ts @@ -5,7 +5,7 @@ import { RewardsDistributorEth__factory } from "types/generated/factories/Reward import { RewardsDistributor__factory } from "types/generated/factories/RewardsDistributor__factory" import { formatUnits } from "ethers/lib/utils" import { Liquidator__factory } from "types" -import rewardsFiles from "./balancer-mta-rewards/20210720.json" +import rewardsFiles from "./balancer-mta-rewards/20210817.json" import { Chain, logTxDetails, usdFormatter } from "./utils" import { getAaveTokens, getAlcxTokens, getBlock, getCompTokens } from "./utils/snap-utils" import { getSigner } from "./utils/signerFactory" diff --git a/tenderly.yaml b/tenderly.yaml index daa6bd61..197cd7b2 100644 --- a/tenderly.yaml +++ b/tenderly.yaml @@ -1,20 +1,20 @@ account_id: 7b4c7a8a-d93e-4ada-825c-104794b6abc6 exports: - staked-token: - chain_config: - berlin_block: 0 - byzantium_block: 0 - constantinople_block: 0 - eip150_block: 0 - eip150_hash: "0x0000000000000000000000000000000000000000000000000000000000000000" - eip155_block: 0 - eip158_block: 0 - homestead_block: 0 - istanbul_block: 0 - petersburg_block: 0 - forked_network: "" - project_slug: alsco77/mstable - protocol: "" - rpc_address: 127.0.0.1:8545 + staked-token: + chain_config: + berlin_block: 0 + byzantium_block: 0 + constantinople_block: 0 + eip150_block: 0 + eip150_hash: "0x0000000000000000000000000000000000000000000000000000000000000000" + eip155_block: 0 + eip158_block: 0 + homestead_block: 0 + istanbul_block: 0 + petersburg_block: 0 + forked_network: "" + project_slug: alsco77/mstable + protocol: "" + rpc_address: 127.0.0.1:8545 project_slug: mstable provider: Hardhat diff --git a/test-utils/machines/standardAccounts.ts b/test-utils/machines/standardAccounts.ts index 5a944935..9445fd04 100644 --- a/test-utils/machines/standardAccounts.ts +++ b/test-utils/machines/standardAccounts.ts @@ -34,6 +34,8 @@ export class StandardAccounts { public mockInterestValidator: Account + public mockRecollateraliser: Account + public mockMasset: Account public async initAccounts(signers: Signer[]): Promise { @@ -56,6 +58,7 @@ export class StandardAccounts { this.questSigner, this.mockSavingsManager, this.mockInterestValidator, + this.mockRecollateraliser, this.mockMasset, ] = this.all return this diff --git a/test/governance/staked-token.spec.ts b/test/governance/staked-token.spec.ts index 53163094..7bb3f6c2 100644 --- a/test/governance/staked-token.spec.ts +++ b/test/governance/staked-token.spec.ts @@ -13,6 +13,7 @@ import { PlatformTokenVendorFactory__factory, SignatureVerifier__factory, StakedToken, + StakedTokenWrapper__factory, StakedToken__factory, } from "types" import { assertBNClose, DEAD_ADDRESS } from "index" @@ -23,7 +24,6 @@ import { getTimestamp, increaseTime } from "@utils/time" import { arrayify, solidityKeccak256 } from "ethers/lib/utils" import { BigNumberish, Signer } from "ethers" import { QuestStatus, QuestType, UserStakingData } from "types/stakedToken" -import { textChangeRangeNewSpan } from "typescript" const signUserQuest = async (user: string, questId: BigNumberish, questSigner: Signer): Promise => { const messageHash = solidityKeccak256(["address", "uint256"], [user, questId]) @@ -46,6 +46,7 @@ describe("Staked Token", () => { const redeployStakedToken = async (): Promise => { deployTime = await getTimestamp() nexus = await new MockNexus__factory(sa.default.signer).deploy(sa.governor.address, DEAD_ADDRESS, DEAD_ADDRESS) + await nexus.setRecollateraliser(sa.mockRecollateraliser.address) rewardToken = await new MockERC20__factory(sa.default.signer).deploy("Reward", "RWD", 18, sa.default.address, 10000000) const signatureVerifier = await new SignatureVerifier__factory(sa.default.signer).deploy() @@ -58,7 +59,6 @@ describe("Staked Token", () => { } const stakedTokenFactory = new StakedToken__factory(stakedTokenLibraryAddresses, sa.default.signer) const stakedTokenImpl = await stakedTokenFactory.deploy( - sa.questSigner.address, nexus.address, rewardToken.address, rewardToken.address, @@ -66,7 +66,12 @@ describe("Staked Token", () => { ONE_DAY.mul(2), ) const rewardsDistributorAddress = DEAD_ADDRESS - const data = stakedTokenImpl.interface.encodeFunctionData("initialize", ["Staked Rewards", "stkRWD", rewardsDistributorAddress]) + const data = stakedTokenImpl.interface.encodeFunctionData("initialize", [ + "Staked Rewards", + "stkRWD", + rewardsDistributorAddress, + sa.questSigner.address, + ]) const stakedTokenProxy = await new AssetProxy__factory(sa.default.signer).deploy(stakedTokenImpl.address, DEAD_ADDRESS, data) return stakedTokenFactory.attach(stakedTokenProxy.address) @@ -112,7 +117,11 @@ describe("Staked Token", () => { expect(await stakedToken.UNSTAKE_WINDOW(), "unstake window").to.eq(ONE_DAY.mul(2)) // eslint-disable-next-line no-underscore-dangle - expect(await stakedToken._signer(), "quest signer").to.eq(sa.questSigner.address) + expect(await stakedToken.questMaster(), "quest master").to.eq(sa.questSigner.address) + + const safetyData = await stakedToken.safetyData() + expect(safetyData.collateralisationRatio, "Collateralisation ratio").to.eq(simpleToExactAmount(1)) + expect(safetyData.slashingPercentage, "Slashing percentage").to.eq(0) }) }) context("staking and delegating", () => { @@ -894,6 +903,25 @@ describe("Staked Token", () => { }) }) }) + context("questMaster", () => { + beforeEach(async () => { + stakedToken = await redeployStakedToken() + expect(await stakedToken.questMaster(), "quest master before").to.eq(sa.questSigner.address) + }) + it("should set questMaster by governor", async () => { + const tx = await stakedToken.connect(sa.governor.signer).setQuestMaster(sa.dummy1.address) + await expect(tx).to.emit(stakedToken, "QuestMaster").withArgs(sa.questSigner.address, sa.dummy1.address) + expect(await stakedToken.questMaster(), "quest master after").to.eq(sa.dummy1.address) + }) + it("should set questMaster by quest master", async () => { + const tx = await stakedToken.connect(sa.questSigner.signer).setQuestMaster(sa.dummy2.address) + await expect(tx).to.emit(stakedToken, "QuestMaster").withArgs(sa.questSigner.address, sa.dummy2.address) + expect(await stakedToken.questMaster(), "quest master after").to.eq(sa.dummy2.address) + }) + it("should fail to set quest master by anyone", async () => { + await expect(stakedToken.connect(sa.dummy3.signer).setQuestMaster(sa.dummy3.address)).to.revertedWith("Not verified") + }) + }) // Important that each action (checkTimestamp, completeQuest, mint) applies this because // scaledBalance could actually decrease, even in these situations, since old seasonMultipliers are slashed @@ -920,6 +948,7 @@ describe("Staked Token", () => { await rewardToken.connect(sa.default.signer).approve(stakedToken.address, simpleToExactAmount(10000)) await stakedToken["stake(uint256,address)"](stakedAmount, sa.default.address) stakedTimestamp = await getTimestamp() + await increaseTime(ONE_WEEK.mul(2)) }) context("should fail when", () => { it("nothing staked", async () => { @@ -944,8 +973,7 @@ describe("Staked Token", () => { expect(stakerDataAfter.cooldownTimestamp, "cooldown timestamp after").to.eq(startCooldownTimestamp) expect(stakerDataAfter.cooldownUnits, "cooldown units after").to.eq(stakedAmount) expect(stakerDataAfter.userBalances.raw, "staked raw balance after").to.eq(0) - // TODO why is weightedTimestamp 1 second behind lastAction? - expect(stakerDataAfter.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(startCooldownTimestamp.sub(1)) + expect(stakerDataAfter.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(stakedTimestamp) expect(stakerDataAfter.userBalances.lastAction, "last action after").to.eq(startCooldownTimestamp) expect(stakerDataAfter.userBalances.permMultiplier, "perm multiplier after").to.eq(0) expect(stakerDataAfter.userBalances.seasonMultiplier, "season multiplier after").to.eq(0) @@ -967,9 +995,7 @@ describe("Staked Token", () => { expect(stakerDataAfter1stCooldown.cooldownTimestamp, "cooldown timestamp after 1st").to.eq(cooldown1stTimestamp) expect(stakerDataAfter1stCooldown.cooldownUnits, "cooldown units after 1st").to.eq(firstCooldown) expect(stakerDataAfter1stCooldown.userBalances.raw, "staked raw balance after 1st").to.eq(stakedAmount.sub(firstCooldown)) - expect(stakerDataAfter1stCooldown.userBalances.weightedTimestamp, "weighted timestamp after 1st").to.eq( - cooldown1stTimestamp.sub(1), - ) + expect(stakerDataAfter1stCooldown.userBalances.weightedTimestamp, "weighted timestamp after 1st").to.eq(stakedTimestamp) expect(stakerDataAfter1stCooldown.userBalances.lastAction, "last action after 1st").to.eq(cooldown1stTimestamp) expect(stakerDataAfter1stCooldown.userBalances.permMultiplier, "perm multiplier after 1st").to.eq(0) expect(stakerDataAfter1stCooldown.userBalances.seasonMultiplier, "season multiplier after 1st").to.eq(0) @@ -1130,7 +1156,7 @@ describe("Staked Token", () => { const endCooldownTimestamp = await getTimestamp() const stakerDataAfter = await snapshotUserStakingData(sa.default.address) expect(stakerDataAfter.cooldownTimestamp, "cooldown timestamp after").to.eq(0) - expect(stakerDataAfter.cooldownUnits, "cooldown percentage after").to.eq(0) + expect(stakerDataAfter.cooldownUnits, "cooldown units after").to.eq(0) expect(stakerDataAfter.userBalances.raw, "staked raw balance after").to.eq(stakedAmount) expect(stakerDataAfter.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(stakedTimestamp) expect(stakerDataAfter.userBalances.lastAction, "last action after").to.eq(endCooldownTimestamp) @@ -1298,7 +1324,7 @@ describe("Staked Token", () => { expect(beforeData.votes, "staker votes before").to.eq(0) expect(beforeData.rewardsBalance, "staker rewards before").to.eq(startingMintAmount.sub(stakedAmount)) expect(beforeData.cooldownTimestamp, "cooldown timestamp before").to.eq(cooldownTimestamp) - expect(beforeData.cooldownUnits, "cooldown percentage before").to.eq(stakedAmount) + expect(beforeData.cooldownUnits, "cooldown units before").to.eq(stakedAmount) // expect(beforeData.userBalances.cooldownMultiplier, "cooldown multiplier before").to.eq(100) }) it("partial withdraw not including fee", async () => { @@ -1311,7 +1337,7 @@ describe("Staked Token", () => { expect(afterData.stakedBalance, "staker staked after").to.eq(0) expect(afterData.votes, "staker votes after").to.eq(0) expect(afterData.cooldownTimestamp, "cooldown timestamp after").to.eq(beforeData.cooldownTimestamp) - expect(afterData.cooldownUnits, "cooldown percentage after").to.eq( + expect(afterData.cooldownUnits, "cooldown units after").to.eq( beforeData.cooldownUnits.sub(withdrawAmount).sub(redemptionFee), ) // expect(afterData.userBalances.cooldownMultiplier, "cooldown multiplier after").to.eq(100) @@ -1324,19 +1350,16 @@ describe("Staked Token", () => { // fee = staked withdraw * 0.1 // fee = withdraw with fee / 1.1 * 0.1 = withdraw with fee / 11 const redemptionFee = stakedAmount.div(11) + const tx2 = await stakedToken.withdraw(stakedAmount, sa.default.address, true, true) await expect(tx2).to.emit(stakedToken, "Withdraw").withArgs(sa.default.address, sa.default.address, stakedAmount) const afterData = await snapshotUserStakingData(sa.default.address) expect(afterData.stakedBalance, "staker stkRWD after").to.eq(0) expect(afterData.votes, "staker votes after").to.eq(0) - expect(afterData.cooldownTimestamp, "staked cooldown start").to.eq(0) - expect(afterData.cooldownUnits, "staked cooldown percentage").to.eq(0) - // expect(afterData.userBalances.cooldownMultiplier, "cooldown multiplier after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start after").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units after").to.eq(0) expect(afterData.userBalances.raw, "staked raw balance after").to.eq(0) - // expect(afterData.rewardsBalance, "staker rewards after").to.eq( - // beforeData.rewardsBalance.add(stakedAmount).sub(redemptionFee), - // ) assertBNClose(afterData.rewardsBalance, beforeData.rewardsBalance.add(stakedAmount).sub(redemptionFee), 1) }) it("not reset the cooldown timer unless all is all unstaked") @@ -1366,7 +1389,7 @@ describe("Staked Token", () => { expect(beforeData.votes, "staker votes before").to.eq(remainingBalance) expect(beforeData.rewardsBalance, "staker rewards before").to.eq(startingMintAmount.sub(stakedAmount)) expect(beforeData.cooldownTimestamp, "cooldown timestamp before").to.eq(cooldownTimestamp) - expect(beforeData.cooldownUnits, "cooldown percentage before").to.eq(cooldownAmount) + expect(beforeData.cooldownUnits, "cooldown units before").to.eq(cooldownAmount) // expect(beforeData.userBalances.cooldownMultiplier, "cooldown multiplier before").to.eq(70) }) it("partial withdraw not including fee", async () => { @@ -1382,7 +1405,7 @@ describe("Staked Token", () => { expect(afterData.stakedBalance, "staker staked after").to.eq(remainingBalance) expect(afterData.votes, "staker votes after").to.eq(remainingBalance) expect(afterData.cooldownTimestamp, "cooldown timestamp after").to.eq(beforeData.cooldownTimestamp) - expect(afterData.cooldownUnits, "cooldown percentage after").to.eq(cooldownAmount.sub(withdrawAmount).sub(redemptionFee)) + expect(afterData.cooldownUnits, "cooldown units after").to.eq(cooldownAmount.sub(withdrawAmount).sub(redemptionFee)) // expect(afterData.userBalances.cooldownMultiplier, "cooldown multiplier after").to.eq(64) // 2000 - 300 - 30 = 1670 expect(afterData.userBalances.raw, "staked raw balance after").to.eq(remainingBalance) @@ -1400,26 +1423,386 @@ describe("Staked Token", () => { const afterData = await snapshotUserStakingData(sa.default.address) expect(afterData.stakedBalance, "staker stkRWD after").to.eq(remainingBalance) expect(afterData.votes, "staker votes after").to.eq(remainingBalance) - expect(afterData.cooldownTimestamp, "staked cooldown start").to.eq(0) - expect(afterData.cooldownUnits, "staked cooldown percentage").to.eq(0) - // expect(afterData.userBalances.cooldownMultiplier, "cooldown multiplier after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start after").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units after").to.eq(0) expect(afterData.userBalances.raw, "staked raw balance after").to.eq(remainingBalance) - // expect(afterData.rewardsBalance, "staker rewards after").to.eq( - // beforeData.rewardsBalance.add(cooldownAmount).sub(redemptionFee), - // ) assertBNClose(afterData.rewardsBalance, beforeData.rewardsBalance.add(cooldownAmount).sub(redemptionFee), 1) }) it("not reset the cooldown timer unless all is all unstaked") it("apply a redemption fee which is added to the pendingRewards from the rewards contract") it("distribute these pendingAdditionalReward with the next notification") }) + context("after 25% slashing and recollateralisation", () => { + const slashingPercentage = simpleToExactAmount(25, 16) + beforeEach(async () => { + stakedToken = await redeployStakedToken() + await rewardToken.connect(sa.default.signer).approve(stakedToken.address, stakedAmount) + await stakedToken["stake(uint256,address)"](stakedAmount, sa.default.address) + await increaseTime(ONE_DAY.mul(7).add(60)) + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + await stakedToken.connect(sa.mockRecollateraliser.signer).emergencyRecollateralisation() + }) + it("should withdraw all incl fee and get 75% of rewards", async () => { + const tx = await stakedToken.withdraw(stakedAmount, sa.default.address, true, false) + await expect(tx).to.emit(stakedToken, "Withdraw").withArgs(sa.default.address, sa.default.address, stakedAmount) + await expect(tx) + .to.emit(rewardToken, "Transfer") + .withArgs(stakedToken.address, sa.default.address, stakedAmount.mul(3).div(4)) + + const afterData = await snapshotUserStakingData(sa.default.address) + expect(afterData.stakedBalance, "staker stkRWD after").to.eq(0) + expect(afterData.votes, "staker votes after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start after").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units after").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(0) + }) + it("should withdraw all excl. fee and get 75% of rewards", async () => { + const tx = await stakedToken.withdraw(stakedAmount, sa.default.address, false, false) + await expect(tx).to.emit(stakedToken, "Withdraw").withArgs(sa.default.address, sa.default.address, stakedAmount) + await expect(tx) + .to.emit(rewardToken, "Transfer") + .withArgs(stakedToken.address, sa.default.address, stakedAmount.mul(3).div(4)) + + const afterData = await snapshotUserStakingData(sa.default.address) + expect(afterData.stakedBalance, "staker stkRWD after").to.eq(0) + expect(afterData.votes, "staker votes after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(0) + }) + it("should partial withdraw and get 75% of rewards", async () => { + const withdrawAmount = stakedAmount.div(10) + const tx = await stakedToken.withdraw(withdrawAmount, sa.default.address, true, false) + await expect(tx).to.emit(stakedToken, "Withdraw").withArgs(sa.default.address, sa.default.address, withdrawAmount) + await expect(tx) + .to.emit(rewardToken, "Transfer") + .withArgs(stakedToken.address, sa.default.address, withdrawAmount.mul(3).div(4)) + + const afterData = await snapshotUserStakingData(sa.default.address) + expect(afterData.stakedBalance, "staker stkRWD after").to.eq(0) + expect(afterData.votes, "staker votes after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units").to.eq(stakedAmount.sub(withdrawAmount)) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(0) + }) + }) + context("calc redemption fee", () => { + let currentTime: BN + before(async () => { + stakedToken = await redeployStakedToken() + currentTime = await getTimestamp() + }) + const runs = [ + { stakedSeconds: BN.from(0), expected: 100, desc: "immediate" }, + { stakedSeconds: ONE_DAY, expected: 100, desc: "1 day" }, + { stakedSeconds: ONE_WEEK, expected: 100, desc: "1 week" }, + { stakedSeconds: ONE_WEEK.mul(2), expected: 100, desc: "2 weeks" }, + { stakedSeconds: ONE_WEEK.mul(21).div(10), expected: 94.52286093, desc: "2.1 weeks" }, + { stakedSeconds: ONE_WEEK.mul(10), expected: 29.77225575, desc: "10 weeks" }, + { stakedSeconds: ONE_WEEK.mul(12), expected: 25, desc: "12 weeks" }, + { stakedSeconds: ONE_WEEK.mul(47), expected: 0.26455763, desc: "47 weeks" }, + { stakedSeconds: ONE_WEEK.mul(48), expected: 0, desc: "48 weeks" }, + { stakedSeconds: ONE_WEEK.mul(50), expected: 0, desc: "50 weeks" }, + ] + runs.forEach((run) => { + it(run.desc, async () => { + expect(await stakedToken.calcRedemptionFeeRate(currentTime.sub(run.stakedSeconds))).to.eq( + simpleToExactAmount(run.expected, 15), + ) + }) + }) + }) }) + context("backward compatibility", () => { + const stakedAmount = simpleToExactAmount(2000) + beforeEach(async () => { + stakedToken = await redeployStakedToken() + await rewardToken.connect(sa.default.signer).approve(stakedToken.address, stakedAmount.mul(2)) + }) + it("createLock", async () => { + const tx = await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(12)) + + const stakedTimestamp = await getTimestamp() + + await expect(tx).to.emit(stakedToken, "Staked").withArgs(sa.default.address, stakedAmount, ZERO_ADDRESS) + await expect(tx).to.emit(stakedToken, "DelegateChanged").not + await expect(tx).to.emit(stakedToken, "DelegateVotesChanged").withArgs(sa.default.address, 0, stakedAmount) + await expect(tx).to.emit(rewardToken, "Transfer").withArgs(sa.default.address, stakedToken.address, stakedAmount) + + const afterData = await snapshotUserStakingData(sa.default.address) + + expect(afterData.cooldownTimestamp, "cooldown timestamp after").to.eq(0) + expect(afterData.cooldownUnits, "cooldown units after").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(stakedAmount) + expect(afterData.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(stakedTimestamp) + expect(afterData.userBalances.lastAction, "last action after").to.eq(stakedTimestamp) + expect(afterData.userBalances.permMultiplier, "perm multiplier after").to.eq(0) + expect(afterData.userBalances.seasonMultiplier, "season multiplier after").to.eq(0) + expect(afterData.userBalances.timeMultiplier, "time multiplier after").to.eq(0) + expect(afterData.stakedBalance, "staked balance after").to.eq(stakedAmount) + expect(afterData.votes, "staker votes after").to.eq(stakedAmount) + + expect(await stakedToken.totalSupply(), "total staked after").to.eq(stakedAmount) + }) + it("increaseLockAmount", async () => { + await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(12)) + await increaseTime(ONE_WEEK.mul(10)) + const increaseAmount = simpleToExactAmount(200) + const newBalance = stakedAmount.add(increaseAmount) + const tx = await stakedToken.increaseLockAmount(increaseAmount) + + const increaseTimestamp = await getTimestamp() + + await expect(tx).to.emit(stakedToken, "Staked").withArgs(sa.default.address, increaseAmount, ZERO_ADDRESS) + await expect(tx).to.emit(stakedToken, "DelegateChanged").not + await expect(tx).to.emit(stakedToken, "DelegateVotesChanged").withArgs(sa.default.address, stakedAmount, newBalance) + await expect(tx).to.emit(rewardToken, "Transfer").withArgs(sa.default.address, stakedToken.address, increaseAmount) + + const afterData = await snapshotUserStakingData(sa.default.address) + + expect(afterData.cooldownTimestamp, "cooldown timestamp after").to.eq(0) + expect(afterData.cooldownUnits, "cooldown units after").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(newBalance) + // expect(afterData.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(increaseTimestamp) + expect(afterData.userBalances.lastAction, "last action after").to.eq(increaseTimestamp) + expect(afterData.userBalances.permMultiplier, "perm multiplier after").to.eq(0) + expect(afterData.userBalances.seasonMultiplier, "season multiplier after").to.eq(0) + expect(afterData.userBalances.timeMultiplier, "time multiplier after").to.eq(0) + expect(afterData.stakedBalance, "staked balance after").to.eq(newBalance) + expect(afterData.votes, "staker votes after").to.eq(newBalance) + + expect(await stakedToken.totalSupply(), "total staked after").to.eq(newBalance) + }) + it("fails to increaseLockAmount if the user has no stake", async () => { + // Fresh slate, fail + await expect(stakedToken.increaseLockAmount(stakedAmount)).to.revertedWith("Nothing to increase") + + // Stake, withdraw, fail + await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(12)) + await stakedToken.startCooldown(stakedAmount) + await increaseTime(ONE_DAY.mul(8)) + await stakedToken.withdraw(stakedAmount, sa.default.address, true, false) + const data = await snapshotUserStakingData() + expect(data.stakedBalance).eq(BN.from(0)) + expect(data.cooldownTimestamp).eq(BN.from(0)) + await expect(stakedToken.increaseLockAmount(stakedAmount)).to.revertedWith("Nothing to increase") + }) + it("increase lock length", async () => { + await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(12)) + const stakedTimestamp = await getTimestamp() + await increaseTime(ONE_WEEK.mul(10)) + + await stakedToken.increaseLockLength(ONE_WEEK.mul(20)) + + const afterData = await snapshotUserStakingData(sa.default.address) + + expect(afterData.cooldownTimestamp, "cooldown timestamp after").to.eq(0) + expect(afterData.cooldownUnits, "cooldown units after").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(stakedAmount) + expect(afterData.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(stakedTimestamp) + expect(afterData.userBalances.lastAction, "last action after").to.eq(stakedTimestamp) + expect(afterData.userBalances.permMultiplier, "perm multiplier after").to.eq(0) + expect(afterData.userBalances.seasonMultiplier, "season multiplier after").to.eq(0) + expect(afterData.userBalances.timeMultiplier, "time multiplier after").to.eq(0) + expect(afterData.stakedBalance, "staked balance after").to.eq(stakedAmount) + expect(afterData.votes, "staker votes after").to.eq(stakedAmount) + + expect(await stakedToken.totalSupply(), "total staked after").to.eq(stakedAmount) + }) + it("first exit", async () => { + await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(20)) + const stakeTimestamp = await getTimestamp() + await increaseTime(ONE_WEEK.mul(18)) + + const tx = await stakedToken.exit() + + await expect(tx).to.emit(stakedToken, "Cooldown").withArgs(sa.default.address, stakedAmount) + + const startCooldownTimestamp = await getTimestamp() + const stakerDataAfter = await snapshotUserStakingData(sa.default.address) + expect(stakerDataAfter.cooldownTimestamp, "cooldown timestamp after").to.eq(startCooldownTimestamp) + expect(stakerDataAfter.cooldownUnits, "cooldown units after").to.eq(stakedAmount) + expect(stakerDataAfter.userBalances.raw, "staked raw balance after").to.eq(0) + expect(stakerDataAfter.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(stakeTimestamp) + expect(stakerDataAfter.userBalances.lastAction, "last action after").to.eq(startCooldownTimestamp) + expect(stakerDataAfter.userBalances.permMultiplier, "perm multiplier after").to.eq(0) + expect(stakerDataAfter.userBalances.seasonMultiplier, "season multiplier after").to.eq(0) + expect(stakerDataAfter.userBalances.timeMultiplier, "time multiplier after").to.eq(20) + expect(stakerDataAfter.stakedBalance, "staked balance after").to.eq(0) + expect(stakerDataAfter.votes, "votes after").to.eq(0) + }) + it("second exit", async () => { + await stakedToken.createLock(stakedAmount, ONE_WEEK.mul(20)) + await increaseTime(ONE_DAY.mul(1)) + await stakedToken.exit() + await increaseTime(ONE_DAY.mul(8)) + + const tx = await stakedToken.exit() + + const redemptionFee = stakedAmount.div(11).add(1) + await expect(tx).to.emit(stakedToken, "Withdraw").withArgs(sa.default.address, sa.default.address, stakedAmount) + await expect(tx) + .to.emit(rewardToken, "Transfer") + .withArgs(stakedToken.address, sa.default.address, stakedAmount.sub(redemptionFee)) + const withdrawTimestamp = await getTimestamp() + const afterData = await snapshotUserStakingData(sa.default.address) + expect(afterData.stakedBalance, "staker stkRWD after").to.eq(0) + expect(afterData.votes, "staker votes after").to.eq(0) + expect(afterData.cooldownTimestamp, "staked cooldown start").to.eq(0) + expect(afterData.cooldownUnits, "staked cooldown units").to.eq(0) + expect(afterData.userBalances.raw, "staked raw balance after").to.eq(0) + // TODO expect(afterData.userBalances.weightedTimestamp, "weighted timestamp after").to.eq(withdrawTimestamp) + expect(afterData.userBalances.lastAction, "last action after").to.eq(withdrawTimestamp) + expect(afterData.rewardsBalance, "staker rewards after").to.eq(startingMintAmount.sub(redemptionFee)) + }) + }) context("interacting from a smart contract", () => { - // Will need to create a sample solidity mock wrapper that has the ability to deposit and withdraw - it("should not be possible to stake and withdraw from a smart contract") + let stakedTokenWrapper + const stakedAmount = simpleToExactAmount(1000) + before(async () => { + stakedToken = await redeployStakedToken() + + stakedTokenWrapper = await new StakedTokenWrapper__factory(sa.default.signer).deploy(rewardToken.address, stakedToken.address) + await rewardToken.transfer(stakedTokenWrapper.address, stakedAmount.mul(3)) + }) + it("should allow governor to whitelist a contract", async () => { + expect(await stakedToken.whitelistedWrappers(stakedTokenWrapper.address), "wrapper not whitelisted before").to.be.false + const tx = await stakedToken.connect(sa.governor.signer).whitelistWrapper(stakedTokenWrapper.address) + await expect(tx).to.emit(stakedToken, "WrapperWhitelisted").withArgs(stakedTokenWrapper.address) + expect(await stakedToken.whitelistedWrappers(stakedTokenWrapper.address), "wrapper whitelisted after").to.be.true + + const tx2 = await stakedTokenWrapper["stake(uint256)"](stakedAmount) + await expect(tx2).to.emit(stakedToken, "Staked").withArgs(stakedTokenWrapper.address, stakedAmount, ZERO_ADDRESS) + }) + it("should allow governor to blacklist a contract", async () => { + const tx = await stakedToken.connect(sa.governor.signer).whitelistWrapper(stakedTokenWrapper.address) + await expect(tx).to.emit(stakedToken, "WrapperWhitelisted").withArgs(stakedTokenWrapper.address) + expect(await stakedToken.whitelistedWrappers(stakedTokenWrapper.address), "wrapper whitelisted").to.be.true + + const tx2 = await stakedToken.connect(sa.governor.signer).blackListWrapper(stakedTokenWrapper.address) + await expect(tx2).to.emit(stakedToken, "WrapperBlacklisted").withArgs(stakedTokenWrapper.address) + expect(await stakedToken.whitelistedWrappers(stakedTokenWrapper.address), "wrapper not whitelisted").to.be.false + + await expect(stakedTokenWrapper["stake(uint256)"](stakedAmount)).to.revertedWith("Not a whitelisted contract") + }) + it("Votes can be delegated to a smart contract", async () => { + await rewardToken.connect(sa.default.signer).approve(stakedToken.address, stakedAmount) + + const tx = await stakedToken["stake(uint256,address)"](stakedAmount, stakedTokenWrapper.address) + + await expect(tx).to.emit(stakedToken, "Staked").withArgs(sa.default.address, stakedAmount, stakedTokenWrapper.address) + }) + context("should not", () => { + it("be possible to stake when not whitelisted", async () => { + await expect(stakedTokenWrapper["stake(uint256)"](stakedAmount)).to.revertedWith("Not a whitelisted contract") + }) + it("be possible to withdraw when not whitelisted", async () => { + await stakedToken.connect(sa.governor.signer).whitelistWrapper(stakedTokenWrapper.address) + await stakedTokenWrapper["stake(uint256)"](stakedAmount) + await stakedToken.connect(sa.governor.signer).blackListWrapper(stakedTokenWrapper.address) + const tx = stakedTokenWrapper.withdraw(stakedAmount, sa.default.address, true, true) + + await expect(tx).to.revertedWith("Not a whitelisted contract") + }) + it("allow non governor to whitelist a contract", async () => { + const tx = stakedToken.whitelistWrapper(stakedTokenWrapper.address) + await expect(tx).to.revertedWith("Only governor can execute") + }) + it("allow non governor to blacklist a contract", async () => { + await stakedToken.connect(sa.governor.signer).whitelistWrapper(stakedTokenWrapper.address) + + const tx = stakedToken.blackListWrapper(stakedTokenWrapper.address) + await expect(tx).to.revertedWith("Only governor can execute") + }) + }) }) + context("recollateralisation", () => { + const stakedAmount = simpleToExactAmount(10000) + beforeEach(async () => { + stakedToken = await redeployStakedToken() + const users = [sa.default, sa.dummy1, sa.dummy2, sa.dummy3, sa.dummy4] + for (const user of users) { + await rewardToken.transfer(user.address, stakedAmount) + await rewardToken.connect(user.signer).approve(stakedToken.address, stakedAmount) + await stakedToken.connect(user.signer)["stake(uint256,address)"](stakedAmount, user.address) + } + }) + it("should allow governor to set 25% slashing", async () => { + const slashingPercentage = simpleToExactAmount(25, 16) + const tx = await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + await expect(tx).to.emit(stakedToken, "SlashRateChanged").withArgs(slashingPercentage) + + const safetyDataAfter = await stakedToken.safetyData() + expect(await safetyDataAfter.slashingPercentage, "slashing percentage after").to.eq(slashingPercentage) + expect(await safetyDataAfter.collateralisationRatio, "collateralisation ratio after").to.eq(simpleToExactAmount(1)) + }) + it("should allow governor to slash a second time before recollateralisation", async () => { + const firstSlashingPercentage = simpleToExactAmount(10, 16) + const secondSlashingPercentage = simpleToExactAmount(20, 16) + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(firstSlashingPercentage) + const tx = stakedToken.connect(sa.governor.signer).changeSlashingPercentage(secondSlashingPercentage) + await expect(tx).to.emit(stakedToken, "SlashRateChanged").withArgs(secondSlashingPercentage) + + const safetyDataAfter = await stakedToken.safetyData() + expect(await safetyDataAfter.slashingPercentage, "slashing percentage after").to.eq(secondSlashingPercentage) + expect(await safetyDataAfter.collateralisationRatio, "collateralisation ratio after").to.eq(simpleToExactAmount(1)) + }) + it("should allow recollateralisation", async () => { + const slashingPercentage = simpleToExactAmount(25, 16) + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + + const tx = stakedToken.connect(sa.mockRecollateraliser.signer).emergencyRecollateralisation() + + // Events + await expect(tx).to.emit(stakedToken, "Recollateralised") + // transfer amount = 5 * 10,000 * 25% = 12,500 + await expect(tx) + .to.emit(rewardToken, "Transfer") + .withArgs(stakedToken.address, sa.mockRecollateraliser.address, simpleToExactAmount(12500)) + + const safetyDataAfter = await stakedToken.safetyData() + expect(await safetyDataAfter.slashingPercentage, "slashing percentage after").to.eq(slashingPercentage) + expect(await safetyDataAfter.collateralisationRatio, "collateralisation ratio after").to.eq( + simpleToExactAmount(1).sub(slashingPercentage), + ) + }) + context("should not allow", () => { + const slashingPercentage = simpleToExactAmount(10, 16) + it("governor to slash after recollateralisation", async () => { + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + await stakedToken.connect(sa.mockRecollateraliser.signer).emergencyRecollateralisation() + + const tx = stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + await expect(tx).to.revertedWith("Only while fully collateralised") + }) + it("slash percentage > 50%", async () => { + const tx = stakedToken.connect(sa.governor.signer).changeSlashingPercentage(simpleToExactAmount(51, 16)) + await expect(tx).to.revertedWith("Cannot exceed 50%") + }) + it("non governor to change slash percentage", async () => { + const tx = stakedToken.changeSlashingPercentage(slashingPercentage) + await expect(tx).to.revertedWith("Only governor can execute") + }) + it("non recollateralisation module to recollateralisation", async () => { + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + const tx = stakedToken.connect(sa.default.signer).emergencyRecollateralisation() + await expect(tx).to.revertedWith("Only Recollateralisation Module") + }) + it("governor to recollateralisation", async () => { + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + const tx = stakedToken.connect(sa.governor.signer).emergencyRecollateralisation() + await expect(tx).to.revertedWith("Only Recollateralisation Module") + }) + it("a second recollateralisation", async () => { + await stakedToken.connect(sa.governor.signer).changeSlashingPercentage(slashingPercentage) + await stakedToken.connect(sa.mockRecollateraliser.signer).emergencyRecollateralisation() + const tx = stakedToken.connect(sa.mockRecollateraliser.signer).emergencyRecollateralisation() + await expect(tx).to.revertedWith("Only while fully collateralised") + }) + }) + }) context("updating lastAction timestamp", () => { it("should be triggered after every WRITE action on the contract") })