From 9a42c75844bd79ac9d805e6a6833ce9fd7b4638c Mon Sep 17 00:00:00 2001 From: Victoria Zotova Date: Wed, 3 Apr 2024 14:34:45 -0400 Subject: [PATCH] Introduces StableYield contract for minting and distributing reward per app per period --- contracts/reward/StableYield.sol | 171 ++++++++++ contracts/staking/IStaking.sol | 19 ++ contracts/staking/TokenStaking.sol | 25 +- contracts/test/StableYieldTestSet.sol | 57 ++++ test/reward/StableYield.test.js | 443 ++++++++++++++++++++++++++ 5 files changed, 708 insertions(+), 7 deletions(-) create mode 100644 contracts/reward/StableYield.sol create mode 100644 contracts/test/StableYieldTestSet.sol create mode 100644 test/reward/StableYield.test.js diff --git a/contracts/reward/StableYield.sol b/contracts/reward/StableYield.sol new file mode 100644 index 0000000..e6af5c1 --- /dev/null +++ b/contracts/reward/StableYield.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ██████████████ ▐████▌ ██████████████ +// ██████████████ ▐████▌ ██████████████ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ +// ▐████▌ ▐████▌ + +pragma solidity ^0.8.9; + +import "../token/T.sol"; +import "../staking/IStaking.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"; + +/// @title Stable yield contract +/// @notice Contract that mints and distributes stable yield reward for participating in Threshold Network. +/// Periodically mints reward for each application based on authorization rate and destributes this rewards based on type of application. +contract StableYield is OwnableUpgradeable { + using AddressUpgradeable for address; + + struct ApplicationInfo { + uint256 stableYield; + uint256 duration; + address distributor; + string receiveRewardMethod; + uint256 lastMint; + } + + uint256 public constant STABLE_YIELD_BASE = 10000; + + T immutable token; + IStaking immutable tokenStaking; + mapping(address => ApplicationInfo) public applicationInfo; + + /// @dev Event emitted by `setApplicationParameters` function. + event ParametersSet( + address indexed application, + uint256 stableYield, + uint256 duration, + address distributor, + string receiveRewardMethod + ); + + /// @dev Event emitted by `mintAndPushReward` function. + event MintedReward(address indexed application, uint96 reward); + + constructor(T _token, IStaking _tokenStaking) { + // calls to check contracts are working + uint256 totalSupply = _token.totalSupply(); + require( + totalSupply > 0 && _tokenStaking.getApplicationsLength() > 0, + "Wrong input parameters" + ); + require( + (STABLE_YIELD_BASE * totalSupply * totalSupply) / + totalSupply / + STABLE_YIELD_BASE == + totalSupply, + "Potential overflow" + ); + token = _token; + tokenStaking = _tokenStaking; + _transferOwnership(_msgSender()); + } + + /// @notice Sets or updates application parameter for minting reward. + /// Can be called only by the governance. + function setApplicationParameters( + address application, + uint256 stableYield, + uint256 duration, + address distributor, + string memory receiveRewardMethod + ) external onlyOwner { + // if stable yield is zero then reward will be no longer minted + require( + (stableYield == 0 || + (stableYield < STABLE_YIELD_BASE && duration > 0)) && + distributor != address(0), + "Wrong input parameters" + ); + ApplicationInfo storage info = applicationInfo[application]; + info.stableYield = stableYield; + info.duration = duration; + info.distributor = distributor; + info.receiveRewardMethod = receiveRewardMethod; + emit ParametersSet( + application, + stableYield, + duration, + distributor, + receiveRewardMethod + ); + } + + /// @notice Mints reward and then pushes it to particular application or distributor. + /// @dev Application must be in `APPROVED` state + function mintAndPushReward(address application) external { + ApplicationInfo storage info = applicationInfo[application]; + require( + info.stableYield != 0, + "Reward parameters are not set for the application" + ); + require( + block.timestamp >= info.lastMint + info.duration, + "New portion of reward is not ready" + ); + IStaking.ApplicationStatus status = tokenStaking.getApplicationStatus( + application + ); + require( + status == IStaking.ApplicationStatus.APPROVED, + "Application is not approved" + ); + uint96 reward = caclulateReward(application, info.stableYield); + info.lastMint = block.timestamp; + //slither-disable-next-line incorrect-equality + if (bytes(info.receiveRewardMethod).length == 0) { + sendToDistributor(info.distributor, reward); + } else { + executeReceiveReward(application, info.receiveRewardMethod, reward); + } + emit MintedReward(application, reward); + } + + function caclulateReward(address application, uint256 stableYield) + internal + view + returns (uint96 reward) + { + uint96 authrorizedOverall = tokenStaking.getAuthorizedOverall( + application + ); + uint256 totalSupply = token.totalSupply(); + // stableYieldPercent * authorizationRate * authorizedOverall = + // (stableYield / STABLE_YIELD_BASE) * (authrorizedOverall / totalSupply) * authorizedOverall + reward = uint96( + (stableYield * authrorizedOverall * authrorizedOverall) / + totalSupply / + STABLE_YIELD_BASE + ); + } + + function sendToDistributor(address distributor, uint96 reward) internal { + token.mint(distributor, reward); + } + + function executeReceiveReward( + address distributor, + string storage receiveRewardMethod, + uint96 reward + ) internal { + token.mint(address(this), reward); + //slither-disable-next-line unused-return + token.approve(distributor, reward); + bytes memory data = abi.encodeWithSignature( + receiveRewardMethod, + reward + ); + //slither-disable-next-line unused-return + distributor.functionCall(data); + } +} diff --git a/contracts/staking/IStaking.sol b/contracts/staking/IStaking.sol index e15f112..5a2a8a7 100644 --- a/contracts/staking/IStaking.sol +++ b/contracts/staking/IStaking.sol @@ -27,6 +27,13 @@ pragma solidity ^0.8.9; /// delegation optimizes the network throughput without compromising the /// security of the owners’ stake. interface IStaking { + enum ApplicationStatus { + NOT_APPROVED, + APPROVED, + PAUSED, + DISABLED + } + // // // Delegating a stake @@ -267,6 +274,18 @@ interface IStaking { /// @notice Returns length of application array function getApplicationsLength() external view returns (uint256); + /// @notice Returns status of the application + function getApplicationStatus(address application) + external + view + returns (ApplicationStatus); + + /// @notice Returns overall auhtorizaed value for the application + function getAuthorizedOverall(address application) + external + view + returns (uint96); + /// @notice Returns length of slashing queue function getSlashingQueueLength() external view returns (uint256); diff --git a/contracts/staking/TokenStaking.sol b/contracts/staking/TokenStaking.sol index defc8a9..652581e 100644 --- a/contracts/staking/TokenStaking.sol +++ b/contracts/staking/TokenStaking.sol @@ -46,13 +46,6 @@ contract TokenStaking is Initializable, IStaking, Checkpoints { T } - enum ApplicationStatus { - NOT_APPROVED, - APPROVED, - PAUSED, - DISABLED - } - struct StakingProviderInfo { uint96 nuInTStake; address owner; @@ -920,6 +913,24 @@ contract TokenStaking is Initializable, IStaking, Checkpoints { return applications.length; } + /// @notice Returns status of the application + function getApplicationStatus(address application) + external + view + returns (ApplicationStatus) + { + return applicationInfo[application].status; + } + + /// @notice Returns overall auhtorizaed value for the application + function getAuthorizedOverall(address application) + external + view + returns (uint96) + { + return applicationInfo[application].authorizedOverall; + } + /// @notice Returns length of slashing queue function getSlashingQueueLength() external view override returns (uint256) { return slashingQueue.length; diff --git a/contracts/test/StableYieldTestSet.sol b/contracts/test/StableYieldTestSet.sol new file mode 100644 index 0000000..bf4276a --- /dev/null +++ b/contracts/test/StableYieldTestSet.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.9; + +import "../token/T.sol"; +import "../staking/IStaking.sol"; + +contract RewardReceiverMock { + T internal immutable token; + + constructor(T _token) { + token = _token; + } + + function receiveReward(uint96 reward) external { + token.transferFrom(msg.sender, address(this), reward); + } +} + +contract TokenStakingMock { + struct ApplicationInfo { + IStaking.ApplicationStatus status; + uint96 authorizedOverall; + } + + mapping(address => ApplicationInfo) public applicationInfo; + + function setApplicationInfo( + address application, + IStaking.ApplicationStatus status, + uint96 authorizedOverall + ) external { + ApplicationInfo storage info = applicationInfo[application]; + info.status = status; + info.authorizedOverall = authorizedOverall; + } + + function getApplicationsLength() external pure returns (uint256) { + return 1; + } + + function getApplicationStatus(address application) + external + view + returns (IStaking.ApplicationStatus) + { + return applicationInfo[application].status; + } + + function getAuthorizedOverall(address application) + external + view + returns (uint96) + { + return applicationInfo[application].authorizedOverall; + } +} diff --git a/test/reward/StableYield.test.js b/test/reward/StableYield.test.js new file mode 100644 index 0000000..98af892 --- /dev/null +++ b/test/reward/StableYield.test.js @@ -0,0 +1,443 @@ +const { expect } = require("chai") + +const { helpers } = require("hardhat") +const { lastBlockTime, mineBlocks, increaseTime } = helpers.time +const { to1e18 } = helpers.number + +const { AddressZero, Zero } = ethers.constants + +const ApplicationStatus = { + NOT_APPROVED: 0, + APPROVED: 1, + PAUSED: 2, + DISABLED: 3, +} + +describe("StableYield", () => { + let tToken + let rewardReceiverMock + + const tAllocation = to1e18("4500000000") // 4.5 Billion + + let tokenStaking + let stableYield + let stableYieldBase // 100% + + let deployer + let distributor + + beforeEach(async () => { + ;[ + deployer, + distributor, + caller, + application1Mock, + application2Mock, + application3Mock, + ] = await ethers.getSigners() + + const T = await ethers.getContractFactory("T") + tToken = await T.deploy() + await tToken.deployed() + + await tToken.mint(deployer.address, tAllocation) + + const TokenStakingMock = await ethers.getContractFactory("TokenStakingMock") + tokenStaking = await TokenStakingMock.deploy() + await tokenStaking.deployed() + + const RewardReceiverMock = await ethers.getContractFactory( + "RewardReceiverMock" + ) + rewardReceiverMock = await RewardReceiverMock.deploy(tToken.address) + await rewardReceiverMock.deployed() + + const StableYield = await ethers.getContractFactory("StableYield") + stableYield = await StableYield.deploy(tToken.address, tokenStaking.address) + await stableYield.deployed() + + stableYieldBase = await stableYield.STABLE_YIELD_BASE() + tToken.connect(deployer).transferOwnership(stableYield.address) + }) + + describe("setApplicationParameters", () => { + const stableYieldApp1 = ethers.BigNumber.from(100) + const duration = ethers.BigNumber.from(60 * 60 * 24) + const receiveRewardMethodEmpty = "" + + context("when caller is not the owner", () => { + it("should revert", async () => { + await expect( + stableYield + .connect(distributor) + .setApplicationParameters( + application1Mock.address, + stableYieldApp1, + duration, + distributor.address, + receiveRewardMethodEmpty + ) + ).to.be.revertedWith("Ownable: caller is not the owner") + }) + }) + + context("when duration is not set", () => { + it("should revert", async () => { + await expect( + stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + stableYieldApp1, + 0, + distributor.address, + receiveRewardMethodEmpty + ) + ).to.be.revertedWith("Wrong input parameters") + }) + }) + + context("when stable yield is bigger 100%", () => { + it("should revert", async () => { + await expect( + stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + stableYieldBase.add(1), + duration, + distributor.address, + receiveRewardMethodEmpty + ) + ).to.be.revertedWith("Wrong input parameters") + }) + }) + + context("when distributor is not set", () => { + it("should revert", async () => { + await expect( + stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + stableYieldApp1, + duration, + AddressZero, + receiveRewardMethodEmpty + ) + ).to.be.revertedWith("Wrong input parameters") + }) + }) + + context("when parameters set for the first time", () => { + let tx + + beforeEach(async () => { + tx = await stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + stableYieldApp1, + duration, + distributor.address, + receiveRewardMethodEmpty + ) + }) + + it("should set parameters", async () => { + const info = await stableYield.applicationInfo(application1Mock.address) + expect(info.stableYield).to.equal(stableYieldApp1) + expect(info.duration).to.equal(duration) + expect(info.distributor).to.equal(distributor.address) + expect(info.receiveRewardMethod).to.equal(receiveRewardMethodEmpty) + }) + + it("should emit ParametersSet event", async () => { + await expect(tx) + .to.emit(stableYield, "ParametersSet") + .withArgs( + application1Mock.address, + stableYieldApp1, + duration, + distributor.address, + receiveRewardMethodEmpty + ) + }) + }) + + context("when reward disabled after minting", () => { + let tx + let lastMint + const receiveRewardMethod = "receiveReward(uint96)" + + beforeEach(async () => { + await tokenStaking.setApplicationInfo( + rewardReceiverMock.address, + ApplicationStatus.APPROVED, + tAllocation.div(2) + ) + await stableYield + .connect(deployer) + .setApplicationParameters( + rewardReceiverMock.address, + stableYieldApp1, + duration, + distributor.address, + receiveRewardMethod + ) + await stableYield.mintAndPushReward(rewardReceiverMock.address) + const info = await stableYield.applicationInfo( + rewardReceiverMock.address + ) + lastMint = info.lastMint + tx = await stableYield + .connect(deployer) + .setApplicationParameters( + rewardReceiverMock.address, + Zero, + duration, + distributor.address, + receiveRewardMethod + ) + }) + + it("should update parameters", async () => { + const info = await stableYield.applicationInfo( + rewardReceiverMock.address + ) + expect(info.stableYield).to.equal(Zero) + expect(info.duration).to.equal(duration) + expect(info.distributor).to.equal(distributor.address) + expect(info.receiveRewardMethod).to.equal(receiveRewardMethod) + expect(info.lastMint).to.equal(lastMint) + }) + + it("should emit ParametersSet event", async () => { + await expect(tx) + .to.emit(stableYield, "ParametersSet") + .withArgs( + rewardReceiverMock.address, + Zero, + duration, + distributor.address, + receiveRewardMethod + ) + }) + }) + }) + + describe("mintAndPushReward", () => { + const stableYieldApp1 = ethers.BigNumber.from(100) + const stableYieldApp2 = ethers.BigNumber.from(250) + const duration = ethers.BigNumber.from(60 * 60 * 24) + const receiveRewardMethodEmpty = "" + const receiveRewardMethod = "receiveReward(uint96)" + const authorization1 = tAllocation.div(4) + const authorization2 = tAllocation.div(3) + + beforeEach(async () => { + await stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + stableYieldApp1, + duration, + distributor.address, + receiveRewardMethodEmpty + ) + await stableYield + .connect(deployer) + .setApplicationParameters( + rewardReceiverMock.address, + stableYieldApp2, + duration, + rewardReceiverMock.address, + receiveRewardMethod + ) + await tokenStaking.setApplicationInfo( + application1Mock.address, + ApplicationStatus.APPROVED, + authorization1 + ) + await tokenStaking.setApplicationInfo( + rewardReceiverMock.address, + ApplicationStatus.APPROVED, + authorization2 + ) + }) + + context("when stable yield is not set for the application", () => { + it("should revert", async () => { + await expect( + stableYield + .connect(caller) + .mintAndPushReward(application2Mock.address) + ).to.be.revertedWith( + "Reward parameters are not set for the application" + ) + }) + }) + + context("when stable yield is reset to zero", () => { + it("should revert", async () => { + await stableYield + .connect(deployer) + .setApplicationParameters( + application1Mock.address, + Zero, + duration, + distributor.address, + receiveRewardMethodEmpty + ) + await expect( + stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + ).to.be.revertedWith( + "Reward parameters are not set for the application" + ) + }) + }) + + context("when not enough time passed since last mint", () => { + it("should revert", async () => { + await stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + await expect( + stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + ).to.be.revertedWith("New portion of reward is not ready") + }) + }) + + context("when application is not approved", () => { + it("should revert", async () => { + await tokenStaking.setApplicationInfo( + application1Mock.address, + ApplicationStatus.NOT_APPROVED, + Zero + ) + await expect( + stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + ).to.be.revertedWith("Application is not approved") + await tokenStaking.setApplicationInfo( + rewardReceiverMock.address, + ApplicationStatus.PAUSED, + Zero + ) + await expect( + stableYield + .connect(caller) + .mintAndPushReward(rewardReceiverMock.address) + ).to.be.revertedWith("Application is not approved") + await tokenStaking.setApplicationInfo( + application1Mock.address, + ApplicationStatus.DISABLED, + Zero + ) + await expect( + stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + ).to.be.revertedWith("Application is not approved") + }) + }) + + context("when reward minted directly for distributor", () => { + let tx + let blockTimestamp + let expectedReward + + beforeEach(async () => { + expectedReward = authorization1 + .mul(authorization1) + .mul(stableYieldApp1) + .div(tAllocation) + .div(stableYieldBase) + tx = await stableYield + .connect(caller) + .mintAndPushReward(application1Mock.address) + blockTimestamp = await lastBlockTime() + }) + + it("should update lastMint parameter", async () => { + const info = await stableYield.applicationInfo(application1Mock.address) + expect(info.lastMint).to.equal(blockTimestamp) + }) + + it("should mint reward for distributor", async () => { + expect(await tToken.balanceOf(distributor.address)).to.equal( + expectedReward + ) + await expect(tx) + .to.emit(tToken, "Transfer") + .withArgs(AddressZero, distributor.address, expectedReward) + }) + + it("should emit MintedReward event", async () => { + await expect(tx) + .to.emit(stableYield, "MintedReward") + .withArgs(application1Mock.address, expectedReward) + }) + }) + + context("when reward pushed through receiver method", () => { + let tx + let blockTimestamp + let expectedReward1 + let expectedReward2 + + beforeEach(async () => { + expectedReward1 = authorization2 + .mul(authorization2) + .mul(stableYieldApp2) + .div(tAllocation) + .div(stableYieldBase) + await stableYield + .connect(caller) + .mintAndPushReward(rewardReceiverMock.address) + const totalSupply = await tToken.totalSupply() + increaseTime(duration) + expectedReward2 = authorization2 + .mul(authorization2) + .mul(stableYieldApp2) + .div(totalSupply) + .div(stableYieldBase) + tx = await stableYield + .connect(caller) + .mintAndPushReward(rewardReceiverMock.address) + blockTimestamp = (await lastBlockTime()) - 1 + }) + + it("should update lastMint parameter", async () => { + const info = await stableYield.applicationInfo( + rewardReceiverMock.address + ) + expect(info.lastMint).to.equal(blockTimestamp) + }) + + it("should push reward to receiver", async () => { + expect(await tToken.balanceOf(rewardReceiverMock.address)).to.equal( + expectedReward1.add(expectedReward2) + ) + await expect(tx) + .to.emit(tToken, "Transfer") + .withArgs( + stableYield.address, + rewardReceiverMock.address, + expectedReward2 + ) + }) + + it("should emit MintedReward event", async () => { + await expect(tx) + .to.emit(stableYield, "MintedReward") + .withArgs(rewardReceiverMock.address, expectedReward2) + }) + }) + }) +})