From ffa3671f28c2499bb2c14294a9c3ca64dfea1cc8 Mon Sep 17 00:00:00 2001 From: Pranav Bhardwaj Date: Mon, 10 Jun 2024 21:08:27 -0400 Subject: [PATCH] add prt staking pool system --- contracts/adapters/FeeSplitExtension.sol | 3 +- contracts/adapters/PrtFeeSplitExtension.sol | 137 ++++ contracts/interfaces/IPrt.sol | 8 + contracts/interfaces/IPrtStakingPool.sol | 15 + contracts/staking/PrtStakingPool.sol | 258 +++++++ contracts/token/Prt.sol | 54 ++ test/adapters/prtFeeSplitExtension.spec.ts | 478 +++++++++++++ test/integration/ethereum/addresses.ts | 4 + .../ethereum/flashMintHyETH.spec.ts | 2 +- .../ethereum/prtStakingPoolHyETH.spec.ts | 337 +++++++++ test/staking/prtStakingPool.spec.ts | 664 ++++++++++++++++++ test/token/prt.spec.ts | 57 ++ utils/contracts/index.ts | 3 + utils/deploys/deployExtensions.ts | 20 + utils/deploys/deployStaking.ts | 18 +- utils/deploys/deployToken.ts | 18 + 16 files changed, 2073 insertions(+), 3 deletions(-) create mode 100644 contracts/adapters/PrtFeeSplitExtension.sol create mode 100644 contracts/interfaces/IPrt.sol create mode 100644 contracts/interfaces/IPrtStakingPool.sol create mode 100644 contracts/staking/PrtStakingPool.sol create mode 100644 contracts/token/Prt.sol create mode 100644 test/adapters/prtFeeSplitExtension.spec.ts create mode 100644 test/integration/ethereum/prtStakingPoolHyETH.spec.ts create mode 100644 test/staking/prtStakingPool.spec.ts create mode 100644 test/token/prt.spec.ts diff --git a/contracts/adapters/FeeSplitExtension.sol b/contracts/adapters/FeeSplitExtension.sol index 1283c001..7be28214 100644 --- a/contracts/adapters/FeeSplitExtension.sol +++ b/contracts/adapters/FeeSplitExtension.sol @@ -91,7 +91,7 @@ contract FeeSplitExtension is BaseExtension, TimeLockUpgrade, MutualUpgrade { * will automatically be sent to this address so reading the balance of the SetToken in the contract after accrual is * sufficient for accounting for all collected fees. */ - function accrueFeesAndDistribute() public { + function accrueFeesAndDistribute() public virtual { // Emits a FeeActualized event streamingFeeModule.accrueFee(setToken); @@ -260,6 +260,7 @@ contract FeeSplitExtension is BaseExtension, TimeLockUpgrade, MutualUpgrade { */ function updateFeeSplit(uint256 _newFeeSplit) external + virtual mutualUpgrade(manager.operator(), manager.methodologist()) { require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); diff --git a/contracts/adapters/PrtFeeSplitExtension.sol b/contracts/adapters/PrtFeeSplitExtension.sol new file mode 100644 index 00000000..b9f4f50b --- /dev/null +++ b/contracts/adapters/PrtFeeSplitExtension.sol @@ -0,0 +1,137 @@ +/* + Copyright 2024 Index Cooperative + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { FeeSplitExtension } from "./FeeSplitExtension.sol"; +import { IBaseManager } from "../interfaces/IBaseManager.sol"; +import { IIssuanceModule } from "../interfaces/IIssuanceModule.sol"; +import { IPrt } from "../interfaces/IPrt.sol"; +import { IPrtStakingPool } from "../interfaces/IPrtStakingPool.sol"; +import { IStreamingFeeModule } from "../interfaces/IStreamingFeeModule.sol"; +import { PreciseUnitMath } from "../lib/PreciseUnitMath.sol"; + +/** + * @title PrtFeeSplitExtension + * @dev Extension that allows for splitting and setting streaming and mint/redeem fees with a + * PRT Staking Pool. The operator can accrue fees from the streaming fee module and distribute + * them to the operator and the PRT Staking Pool, snapshotting the PRT Staking Pool. The operator + * can update the PRT staking pool address and the fee split between the operator and the + * PRT staking pool. + */ +contract PrtFeeSplitExtension is FeeSplitExtension { + using Address for address; + using PreciseUnitMath for uint256; + using SafeMath for uint256; + + /* ============ Events ============ */ + + event PrtFeesDistributed( + address indexed operatorFeeRecipient, + address indexed prtStakingPool, + uint256 operatorTake, + uint256 prtTake + ); + + /* ============ State Variables ============ */ + + IPrt public prt; + IPrtStakingPool public prtStakingPool; + + /* ============ Constructor ============ */ + + constructor( + IBaseManager _manager, + IStreamingFeeModule _streamingFeeModule, + IIssuanceModule _issuanceModule, + uint256 _operatorFeeSplit, + address _operatorFeeRecipient, + IPrt _prt + ) + public + FeeSplitExtension( + _manager, + _streamingFeeModule, + _issuanceModule, + _operatorFeeSplit, + _operatorFeeRecipient + ) + { + require(_prt.setToken() == address(manager.setToken()), "SetToken mismatch with Prt"); + prt = _prt; + } + + /* ============ External Functions ============ */ + + /** + * @notice ONLY OPERATOR: Updates PRT staking pool. PRT staking pool must have this extension set as the feeSplitExtension. + * @param _prtStakingPool Address of the new PRT staking pool + */ + function updatePrtStakingPool(IPrtStakingPool _prtStakingPool) external onlyOperator { + require(address(_prtStakingPool) != address(0), "Zero address not valid"); + require(_prtStakingPool.feeSplitExtension() == address(this), "PrtFeeSplitExtension must be set"); + prtStakingPool = _prtStakingPool; + } + + /** + * @notice ONLY OPERATOR: Accrues fees from streaming fee module. Gets resulting balance after fee accrual, calculates fees for + * operator and PRT staking pool, and sends to operator fee recipient and PRT Staking Pool respectively. NOTE: mint/redeem fees + * will automatically be sent to this address so reading the balance of the SetToken in the contract after accrual is + * sufficient for accounting for all collected fees. If the PRT take is greater than 0, the PRT Staking Pool will accrue the fees + * and update the snapshot. + */ + function accrueFeesAndDistribute() public override onlyOperator { + // Emits a FeeActualized event + streamingFeeModule.accrueFee(setToken); + + uint256 totalFees = setToken.balanceOf(address(this)); + + uint256 operatorTake = totalFees.preciseMul(operatorFeeSplit); + uint256 prtTake = totalFees.sub(operatorTake); + + if (operatorTake > 0) { + setToken.transfer(operatorFeeRecipient, operatorTake); + } + + // Accrue PRT Staking Pool rewards and update snapshot + if (prtTake > 0) { + setToken.approve(address(prtStakingPool), prtTake); + prtStakingPool.accrue(prtTake); + } + + emit PrtFeesDistributed(operatorFeeRecipient, address(prtStakingPool), operatorTake, prtTake); + } + + /** + * @notice Updates fee split between operator and PRT Staking Pool. Split defined in precise units (1% = 10^16). + * Does not accrue fees and snapshot PRT Staking Pool. + * @param _newFeeSplit Percent of fees in precise units (10^16 = 1%) sent to operator, (rest go to the PRT Staking Pool). + */ + function updateFeeSplit(uint256 _newFeeSplit) + external + override + onlyOperator + { + require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); + operatorFeeSplit = _newFeeSplit; + } +} diff --git a/contracts/interfaces/IPrt.sol b/contracts/interfaces/IPrt.sol new file mode 100644 index 00000000..a2903d81 --- /dev/null +++ b/contracts/interfaces/IPrt.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity 0.6.10; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IPrt is IERC20 { + function setToken() external view returns (address); +} diff --git a/contracts/interfaces/IPrtStakingPool.sol b/contracts/interfaces/IPrtStakingPool.sol new file mode 100644 index 00000000..33d2cbff --- /dev/null +++ b/contracts/interfaces/IPrtStakingPool.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity ^0.6.10; + +interface IPrtStakingPool { + function stake(uint256 _amount) external; + function unstake(uint256 _amount) external; + function accrue(uint256 _amount) external; + function claim() external; + function setFeeSplitExtension(address _feeSplitExtension) external; + function getCurrentId() external view returns (uint256); + function getPendingRewards(address _account) external view returns (uint256); + function getSnapshotRewards(uint256 _snapshotId, address _account) external view returns (uint256); + function feeSplitExtension() external view returns (address); + function prt() external view returns (address); +} diff --git a/contracts/staking/PrtStakingPool.sol b/contracts/staking/PrtStakingPool.sol new file mode 100644 index 00000000..6cd1741b --- /dev/null +++ b/contracts/staking/PrtStakingPool.sol @@ -0,0 +1,258 @@ +/* + Copyright 2024 Index Cooperative + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity ^0.6.10; +pragma experimental ABIEncoderV2; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC20Snapshot } from "@openzeppelin/contracts/token/ERC20/ERC20Snapshot.sol"; +import { Math } from "@openzeppelin/contracts/math/Math.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { IPrt } from "../interfaces/IPrt.sol"; +import { ISetToken } from "../interfaces/ISetToken.sol"; + +/** + * @title PrtStakingPool + * @author Index Cooperative + * @dev A contract for staking PRT tokens and distributing SetTokens. + */ +contract PrtStakingPool is Ownable, ERC20Snapshot, ReentrancyGuard { + using SafeMath for uint256; + + /* ============ Events ============ */ + + event FeeSplitExtensionChanged(address _newFeeSplitExtension); + + /* ============ State Variables ============ */ + + /// @notice SetToken to be distributed to stakers + ISetToken public immutable setToken; + + /// @notice PRT token to be staked + IPrt public immutable prt; + + /// @notice PRT Fee split extension which accrues fees and distributes setToken + address public feeSplitExtension; + + /// @notice Snapshot id of the last claim for each staker + mapping(address => uint256) public lastSnapshotId; + + /// @notice Amount of setToken accrued and distributed with each snapshot + uint256[] public accrueSnapshots; + + /* ============ Modifiers ============ */ + + /** + * @dev Modifier to restrict snapshot calls to only the fee split extension. + */ + modifier onlyFeeSplitExtension() { + require(msg.sender == feeSplitExtension, "Must be FeeSplitExtension"); + _; + } + + /* ========== Constructor ========== */ + + /** + * @notice Constructor to initialize the PRT Staking Pool. + * @param _name Name of the staked PRT token + * @param _symbol Symbol of the staked PRT token + * @param _prt Instance of the PRT token contract + * @param _feeSplitExtension Address of the PrtFeeSplitExtension contract + */ + constructor( + string memory _name, + string memory _symbol, + IPrt _prt, + address _feeSplitExtension + ) + public + ERC20(_name, _symbol) + { + prt = _prt; + setToken = ISetToken(_prt.setToken()); + feeSplitExtension = _feeSplitExtension; + } + + /* ========== External Functions ========== */ + + /** + * @notice Stake `amount` of PRT tokens from `msg.sender` and mint staked PRT tokens. + * @param _amount The amount of PRT tokens to stake + */ + function stake(uint256 _amount) external nonReentrant { + require(_amount > 0, "Cannot stake 0"); + prt.transferFrom(msg.sender, address(this), _amount); + super._mint(msg.sender, _amount); + } + + /** + * @notice Unstake `amount` of PRT tokens by `msg.sender`. + * @param _amount The amount of PRT tokens to unstake + */ + function unstake(uint256 _amount) public nonReentrant { + require(_amount > 0, "Cannot unstake 0"); + super._burn(msg.sender, _amount); + prt.transfer(msg.sender, _amount); + } + + /** + * @notice ONLY FEE SPLIT EXTENSION: Accrue SetTokens and update snapshot. + * @param _amount The amount of SetTokens to accrue + */ + function accrue(uint256 _amount) external nonReentrant onlyFeeSplitExtension { + setToken.transferFrom(msg.sender, address(this), _amount); + accrueSnapshots.push(_amount); + super._snapshot(); + } + + /** + * @notice Claim the staking rewards from pending snapshots for `msg.sender`. + */ + function claim() public nonReentrant { + uint256 currentId = getCurrentId(); + uint256 amount = _getPendingRewards(currentId, msg.sender); + require(amount > 0, "No rewards to claim"); + lastSnapshotId[msg.sender] = currentId; + setToken.transfer(msg.sender, amount); + } + + /** + * @notice ONLY OWNER: Update the PrtFeeSplitExtension address. + */ + function setFeeSplitExtension(address _feeSplitExtension) external onlyOwner { + feeSplitExtension = _feeSplitExtension; + FeeSplitExtensionChanged(_feeSplitExtension); + } + + /* ========== View Functions ========== */ + + /** + * @notice Get the current snapshot id. + * @return The current snapshot id + */ + function getCurrentId() public view returns (uint256) { + return accrueSnapshots.length; + } + + /** + * @notice Get pending rewards for an account. + * @param _account The address of the account + * @return The pending rewards for the account + */ + function getPendingRewards( + address _account + ) external view returns (uint256) { + uint256 currentId = getCurrentId(); + return _getPendingRewards(currentId, _account); + } + + /** + * @notice Get rewards for an account from a specific snapshot id. + * @param _snapshotId The snapshot id + * @param _account The address of the account + * @return The rewards for the account from the snapshot id + */ + function getSnapshotRewards( + uint256 _snapshotId, + address _account + ) external view returns (uint256) { + return _getSnapshotRewards(_snapshotId, _account); + } + + /** + * @notice Get account summary for a specific snapshot id. + * @param _snapshotId The snapshot id + * @param _account The address of the account + * @return snapshotRewards The rewards for the account from the snapshot id + * @return totalRewards The total rewards accrued from the snapshot id + * @return totalSupply The total staked supply at the snapshot id + * @return balance The staked balance of the account at the snapshot id + */ + function getSnapshotSummary( + uint256 _snapshotId, + address _account + ) + external + view + returns ( + uint256 snapshotRewards, + uint256 totalRewards, + uint256 totalSupply, + uint256 balance + ) + { + snapshotRewards = _getSnapshotRewards(_snapshotId, _account); + totalRewards = accrueSnapshots[_snapshotId]; + totalSupply = totalSupplyAt(_snapshotId + 1); + balance = balanceOfAt(_account, _snapshotId + 1); + } + + /** + * @notice Get accrue snapshots. + * @return The accrue snapshots + */ + function getAccrueSnapshots() external view returns(uint256[] memory) { + return accrueSnapshots; + } + + /* ========== Internal Functions ========== */ + + /** + * @dev Get pending rewards for an account. + * @param _currentId The current snapshot id + * @param _account The address of the account + * @return amount The pending rewards for the account + */ + function _getPendingRewards( + uint256 _currentId, + address _account + ) + private + view + returns (uint256 amount) + { + uint256 lastRewardId = lastSnapshotId[_account]; + for (uint256 i = lastRewardId; i < _currentId; i++) { + amount += _getSnapshotRewards(i, _account); + } + } + + /** + * @dev Get rewards for an account from a specific snapshot id. + * @param _snapshotId The snapshot id + * @param _account The address of the account + * @return The rewards for the account from the snapshot id + */ + function _getSnapshotRewards( + uint256 _snapshotId, + address _account + ) + private + view + returns (uint256) + { + return accrueSnapshots[_snapshotId].mul( + balanceOfAt(_account, _snapshotId + 1) + ).div( + totalSupplyAt(_snapshotId + 1) + ); + } +} diff --git a/contracts/token/Prt.sol b/contracts/token/Prt.sol new file mode 100644 index 00000000..abc8912d --- /dev/null +++ b/contracts/token/Prt.sol @@ -0,0 +1,54 @@ +/* + Copyright 2024 Index Cooperative + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title Prt + * @author Index Cooperative + * @notice Standard ERC20 token with a fixed supply allocated to a distributor. Associated with a SetToken. + */ +contract Prt is ERC20 { + /// @notice Address of the SetToken associated with this Prt token. + address public immutable setToken; + + /** + * @notice Constructor for the Prt token. + * @dev Mints the total supply of tokens and assigns them to the distributor. + * @param _name The name of the Prt token. + * @param _symbol The symbol of the Prt token. + * @param _setToken The address of the SetToken associated with this Prt token. + * @param _distributor The address that will receive and distribute the total supply of Prt tokens. + * @param _totalSupply The total supply of Prt tokens to be minted and distributed. + */ + constructor( + string memory _name, + string memory _symbol, + address _setToken, + address _distributor, + uint256 _totalSupply + ) public + ERC20(_name, _symbol) + { + setToken = _setToken; + _mint(_distributor, _totalSupply); + _setupDecimals(18); + } +} diff --git a/test/adapters/prtFeeSplitExtension.spec.ts b/test/adapters/prtFeeSplitExtension.spec.ts new file mode 100644 index 00000000..d3bd9e1b --- /dev/null +++ b/test/adapters/prtFeeSplitExtension.spec.ts @@ -0,0 +1,478 @@ +import "module-alias/register"; + +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ZERO, ONE_YEAR_IN_SECONDS } from "@utils/constants"; +import { Prt, PrtFeeSplitExtension, BaseManagerV2, PrtStakingPool } from "@utils/contracts/index"; +import { SetToken } from "@utils/contracts/setV2"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getSetFixture, + getStreamingFee, + getStreamingFeeInflationAmount, + getTransactionTimestamp, + getWaffleExpect, + increaseTimeAsync, + preciseMul, + getRandomAccount +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; +import { BigNumber, ContractTransaction } from "ethers"; + +const expect = getWaffleExpect(); + +describe.only("PrtFeeSplitExtension", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + let operatorFeeRecipient: Account; + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + let setToken: SetToken; + + let prt: Prt; + + let baseManagerV2: BaseManagerV2; + let feeExtension: PrtFeeSplitExtension; + + before(async () => { + [ + owner, + methodologist, + operator, + operatorFeeRecipient, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSetFixture(owner.address); + await setV2Setup.initialize(); + + setToken = await setV2Setup.createSetToken( + [setV2Setup.dai.address], + [ether(1)], + [setV2Setup.debtIssuanceModule.address, setV2Setup.streamingFeeModule.address] + ); + + // Deploy BaseManager + baseManagerV2 = await deployer.manager.deployBaseManagerV2( + setToken.address, + operator.address, + methodologist.address + ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); + + const feeRecipient = baseManagerV2.address; + const maxStreamingFeePercentage = ether(.1); + const streamingFeePercentage = ether(.02); + const streamingFeeSettings = { + feeRecipient, + maxStreamingFeePercentage, + streamingFeePercentage, + lastStreamingFeeTimestamp: ZERO, + }; + await setV2Setup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); + + await setV2Setup.debtIssuanceModule.initialize( + setToken.address, + ether(.1), + ether(.01), + ether(.005), + baseManagerV2.address, + ADDRESS_ZERO + ); + + // Deploy Prt + prt = await deployer.token.deployPrt( + "PRT", + "PRT", + setToken.address, + owner.address, + ether(100_000) + ); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectManager: Address; + let subjectStreamingFeeModule: Address; + let subjectDebtIssuanceModule: Address; + let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + let subjectPrt: Address; + + beforeEach(async () => { + subjectManager = baseManagerV2.address; + subjectStreamingFeeModule = setV2Setup.streamingFeeModule.address; + subjectDebtIssuanceModule = setV2Setup.debtIssuanceModule.address; + subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operatorFeeRecipient.address; + subjectPrt = prt.address; + }); + + async function subject(): Promise { + return await deployer.extensions.deployPrtFeeSplitExtension( + subjectManager, + subjectStreamingFeeModule, + subjectDebtIssuanceModule, + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient, + subjectPrt, + ); + } + + it("should set the correct PRT address", async () => { + const feeExtension = await subject(); + + const actualPrt = await feeExtension.prt(); + expect(actualPrt).to.eq(prt.address); + }); + }); + + context("when fee extension is deployed and system fully set up", async () => { + let prtStakingPool: PrtStakingPool; + const operatorSplit: BigNumber = ether(.7); + + beforeEach(async () => { + feeExtension = await deployer.extensions.deployPrtFeeSplitExtension( + baseManagerV2.address, + setV2Setup.streamingFeeModule.address, + setV2Setup.debtIssuanceModule.address, + operatorSplit, + operatorFeeRecipient.address, + prt.address + ); + + await baseManagerV2.connect(operator.wallet).addExtension(feeExtension.address); + + // Transfer ownership to BaseManager + await setToken.setManager(baseManagerV2.address); + + // Protect StreamingFeeModule + await baseManagerV2 + .connect(operator.wallet) + .protectModule(setV2Setup.streamingFeeModule.address, [feeExtension.address]); + + // Set extension as fee recipient + await feeExtension.connect(operator.wallet).updateFeeRecipient(feeExtension.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(feeExtension.address); + + // Deploy PrtStakingPool + prtStakingPool = await deployer.staking.deployPrtStakingPool( + "PRT Staking Pool", + "sPRT", + prt.address, + feeExtension.address, + ); + }); + + describe("#updatePrtStakingPool", async () => { + let subjectCaller: Account; + let subjectNewPrtStakingPool: Address; + + beforeEach(async () => { + subjectCaller = operator; + subjectNewPrtStakingPool = prtStakingPool.address; + }); + + async function subject(): Promise { + return await feeExtension + .connect(subjectCaller.wallet) + .updatePrtStakingPool(subjectNewPrtStakingPool); + } + + it("sets the new PRT Staking Pool", async () => { + await subject(); + + const newPrtStakingPool = await feeExtension.prtStakingPool(); + expect(newPrtStakingPool).to.eq(subjectNewPrtStakingPool); + }); + + describe("when the new PRT Staking Pool is address zero", async () => { + beforeEach(async () => { + subjectNewPrtStakingPool = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Zero address not valid"); + }); + }); + + describe("when there is a FeeExtension mismatch", async () => { + beforeEach(async () => { + const wrongPrtPool = await deployer.staking.deployPrtStakingPool( + "PRT Staking Pool", + "sPRT", + prt.address, + ADDRESS_ZERO, // Use zero address instead of FeeExtension + ); + subjectNewPrtStakingPool = wrongPrtPool.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("PrtFeeSplitExtension must be set"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#accrueFeesAndDistribute", async () => { + let mintedTokens: BigNumber; + const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; + + let subjectCaller: Account; + + beforeEach(async () => { + mintedTokens = ether(2); + await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); + await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); + + await increaseTimeAsync(timeFastForward); + + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + + subjectCaller = operator; + }); + + async function subject(): Promise { + return await feeExtension.connect(subjectCaller.wallet).accrueFeesAndDistribute(); + } + + it("should send correct amount of fees to operator fee recipient and PRT Staking Pool", async () => { + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + const txnTimestamp = await getTransactionTimestamp(subject()); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedPrtStakingPoolTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const prtStakingPoolBalance = await setToken.balanceOf(prtStakingPool.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(prtStakingPoolBalance).to.eq(expectedPrtStakingPoolTake); + }); + + it("should emit a PrtFeesDistributed event", async () => { + await expect(subject()).to.emit(feeExtension, "PrtFeesDistributed"); + }); + + it("should snapshot the PRT Staking Pool correctly", async () => { + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + const prtStakingPoolPreSnapshot = await prtStakingPool.getCurrentId(); + const prtStakingPoolPreSnapshotBalance = await setToken.balanceOf(prtStakingPool.address); + + const txnTimestamp = await getTransactionTimestamp(subject()); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedPrtStakingPoolTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const prtStakingPoolPostSnapshot = await prtStakingPool.getCurrentId(); + const prtStakingPoolPostSnapshotBalance = await setToken.balanceOf(prtStakingPool.address); + + const storedPrtStakingPoolTake = await prtStakingPool.accrueSnapshots(prtStakingPoolPostSnapshot.sub(1)); + + expect(prtStakingPoolPreSnapshot).to.eq(prtStakingPoolPostSnapshot.sub(1)); + expect(prtStakingPoolPreSnapshotBalance).to.eq(prtStakingPoolPostSnapshotBalance.sub(expectedPrtStakingPoolTake)); + expect(storedPrtStakingPoolTake).to.eq(expectedPrtStakingPoolTake); + }); + + describe("when PRT Staking Pool fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updateFeeSplit(ether(1)); + }); + + it("should not send fees to the PRT Staking Pool", async () => { + const preMethodologistBalance = await setToken.balanceOf(methodologist.address); + + await subject(); + + const postMethodologistBalance = await setToken.balanceOf(methodologist.address); + expect(postMethodologistBalance.sub(preMethodologistBalance)).to.eq(ZERO); + }); + + it("should create a snapshot on the PRT Staking Pool", async () => { + const preSnapshotId = await prtStakingPool.getCurrentId(); + + await subject(); + + const postSnapshotId = await prtStakingPool.getCurrentId(); + expect(postSnapshotId).to.eq(preSnapshotId); + }); + }); + + describe("when operator fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updateFeeSplit(ZERO); + }); + + it("should not send fees to operator fee recipient", async () => { + const preOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + + await subject(); + + const postOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + expect(postOperatorFeeRecipientBalance.sub(preOperatorFeeRecipientBalance)).to.eq(ZERO); + }); + }); + + describe("when extension has fees accrued, is removed and no longer the feeRecipient", () => { + let txnTimestamp: BigNumber; + let feeState: any; + let expectedFeeInflation: BigNumber; + let totalSupply: BigNumber; + + beforeEach(async () => { + feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + totalSupply = await setToken.totalSupply(); + + // Accrue fees to extension by StreamingFeeModule by direct call + txnTimestamp = await getTransactionTimestamp( + setV2Setup.streamingFeeModule.accrueFee(setToken.address) + ); + + expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + // Change fee recipient to baseManagerV2; + await feeExtension.connect(operator.wallet).updateFeeRecipient(baseManagerV2.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(baseManagerV2.address); + + // Revoke extension authorization + await baseManagerV2.connect(operator.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + await baseManagerV2.connect(methodologist.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + // Remove extension + await baseManagerV2.connect(operator.wallet).removeExtension(feeExtension.address); + }); + + it("should send residual fees to operator fee recipient and PRT Staking Pool", async () => { + await subject(); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const prtStakingPoolBalance = await setToken.balanceOf(prtStakingPool.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(prtStakingPoolBalance).to.eq(expectedMethodologistTake); + }); + }); + }); + + describe("#updateFeeSplit", async () => { + let subjectNewFeeSplit: BigNumber; + let subjectOperatorCaller: Account; + + const mintedTokens: BigNumber = ether(2); + const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; + + beforeEach(async () => { + await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); + await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); + + await increaseTimeAsync(timeFastForward); + + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + + subjectNewFeeSplit = ether(.5); + subjectOperatorCaller = operator; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeSplit(subjectNewFeeSplit); + } + + it("should not accrue fees", async () => { + const operatorFeeRecipientBalanceBefore = await setToken.balanceOf(operatorFeeRecipient.address); + const prtStakingPoolBalanceBefore = await setToken.balanceOf(prtStakingPool.address); + await subject(subjectOperatorCaller); + + const operatorFeeRecipientBalanceAfter = await setToken.balanceOf(operatorFeeRecipient.address); + const prtStakingPoolBalanceAfter = await setToken.balanceOf(prtStakingPool.address); + + expect(operatorFeeRecipientBalanceAfter).to.eq(operatorFeeRecipientBalanceBefore); + expect(prtStakingPoolBalanceAfter).to.eq(prtStakingPoolBalanceBefore); + }); + + it("sets the new fee split", async () => { + await subject(subjectOperatorCaller); + + const actualFeeSplit = await feeExtension.operatorFeeSplit(); + + expect(actualFeeSplit).to.eq(subjectNewFeeSplit); + }); + + describe("when fee splits is >100%", async () => { + beforeEach(async () => { + subjectNewFeeSplit = ether(1.1); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Fee must be less than 100%"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be operator"); + }); + }); + }); + }); +}); diff --git a/test/integration/ethereum/addresses.ts b/test/integration/ethereum/addresses.ts index 34dc4870..bb6ef572 100644 --- a/test/integration/ethereum/addresses.ts +++ b/test/integration/ethereum/addresses.ts @@ -37,6 +37,7 @@ export const PRODUCTION_ADDRESSES = { rsEth: "0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7", rswEth: "0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0", acrossWethLP: "0x28F77208728B0A45cAb24c4868334581Fe86F95B", + hyEth: "0xc4506022Fb8090774E8A628d5084EED61D9B99Ee", }, whales: { stEth: "0xdc24316b9ae028f1497c275eb9192a3ea0f67022", @@ -99,6 +100,9 @@ export const PRODUCTION_ADDRESSES = { constantPriceAdapter: "0x13c33656570092555Bf27Bdf53Ce24482B85D992", linearPriceAdapter: "0x237F7BBe0b358415bE84AB6d279D4338C0d026bB", setTokenCreator: "0x2758BF6Af0EC63f1710d3d7890e1C263a247B75E", + streamingFeeModule: "0x165EDF07Bb61904f47800e13F5120E64C4B9A186", + debtIssuanceModuleV2_1: "0x04b59F9F09750C044D7CfbC177561E409085f0f3", + feeRecipient: "0xbf14566a37D96d55485bD281f5E7c547883A54C8", }, lending: { aave: { diff --git a/test/integration/ethereum/flashMintHyETH.spec.ts b/test/integration/ethereum/flashMintHyETH.spec.ts index a6be3e55..f20d205d 100644 --- a/test/integration/ethereum/flashMintHyETH.spec.ts +++ b/test/integration/ethereum/flashMintHyETH.spec.ts @@ -50,7 +50,7 @@ const NO_OP_SWAP_DATA: SwapData = { }; if (process.env.INTEGRATIONTEST) { - describe.only("FlashMintHyETH - Integration Test", async () => { + describe("FlashMintHyETH - Integration Test", async () => { const addresses = PRODUCTION_ADDRESSES; let owner: Account; let deployer: DeployHelper; diff --git a/test/integration/ethereum/prtStakingPoolHyETH.spec.ts b/test/integration/ethereum/prtStakingPoolHyETH.spec.ts new file mode 100644 index 00000000..3c22ff54 --- /dev/null +++ b/test/integration/ethereum/prtStakingPoolHyETH.spec.ts @@ -0,0 +1,337 @@ +import "module-alias/register"; +import { Account } from "@utils/types"; +import DeployHelper from "@utils/deploys"; +import { getAccounts, getWaffleExpect } from "@utils/index"; +import { increaseTimeAsync, setBlockNumber } from "@utils/test/testingUtils"; +import { + BaseManagerV2, + BaseManagerV2__factory, + Prt, + PrtFeeSplitExtension, + PrtStakingPool, + SetToken, + SetToken__factory, +} from "../../../typechain"; +import { PRODUCTION_ADDRESSES } from "./addresses"; +import { ether } from "@utils/index"; +import { impersonateAccount } from "./utils"; +import { JsonRpcSigner } from "@ethersproject/providers"; +import { ONE_MONTH_IN_SECONDS } from "@utils/constants"; + +const expect = getWaffleExpect(); + +if (process.env.INTEGRATIONTEST) { + describe.only("PrtStakingPool HyETH - Integration Test", async () => { + const addresses = PRODUCTION_ADDRESSES; + + let owner: Account; + let bob: Account; + let alice: Account; + let carol: Account; + let deployer: DeployHelper; + + let hyEth: SetToken; + let baseManager: BaseManagerV2; + let operator: JsonRpcSigner; + let methodologist: JsonRpcSigner; + + setBlockNumber(20064598, true); + + before(async () => { + [ + owner, + bob, + alice, + carol, + ] = await getAccounts(); + deployer = new DeployHelper(owner.wallet); + + hyEth = SetToken__factory.connect(addresses.tokens.hyEth, owner.wallet); + baseManager = BaseManagerV2__factory.connect(await hyEth.manager(), owner.wallet); + + operator = await impersonateAccount(await baseManager.operator()); + methodologist = await impersonateAccount(await baseManager.methodologist()); + }); + + context("When the PRT, PRT Staking Pool, and Prt Fee Split Extension are deployed and setup", () => { + const prtName = "High Yield ETH Index PRT"; + const prtSymbol = "prtHyETH"; + const prtSupply = ether(10_000); + + const prtStakingPoolName = "High Yield ETH Index Staked PRT"; + const prtStakingPoolSymbol = "sPrtHyETH"; + + const prtFeeSplit = ether(0.7); // 70-30 split + + let prt: Prt; + let prtFeeSplitExtension: PrtFeeSplitExtension; + let prtStakingPool: PrtStakingPool; + + before(async () => { + prt = await deployer.token.deployPrt( + prtName, + prtSymbol, + hyEth.address, + owner.address, + prtSupply + ); + + prtFeeSplitExtension = await deployer.extensions.deployPrtFeeSplitExtension( + baseManager.address, + addresses.setFork.streamingFeeModule, + addresses.setFork.debtIssuanceModuleV2_1, + prtFeeSplit, + addresses.setFork.feeRecipient, + prt.address + ); + await baseManager.connect(operator).addExtension(prtFeeSplitExtension.address); + await prtFeeSplitExtension.connect(operator).updateFeeRecipient(prtFeeSplitExtension.address); + await prtFeeSplitExtension.connect(methodologist).updateFeeRecipient(prtFeeSplitExtension.address); + + prtStakingPool = await deployer.staking.deployPrtStakingPool( + prtStakingPoolName, + prtStakingPoolSymbol, + prt.address, + prtFeeSplitExtension.address + ); + await prtFeeSplitExtension.connect(operator).updatePrtStakingPool(prtStakingPool.address); + }); + + it("should set the PRT state correctly", async () => { + expect(await prt.decimals()).to.eq(18); + expect(await prt.totalSupply()).to.eq(prtSupply); + expect(await prt.name()).to.eq(prtName); + expect(await prt.symbol()).to.eq(prtSymbol); + }); + + it("should distribute the PRT to the owner", async () => { + expect(await prt.balanceOf(owner.address)).to.eq(prtSupply); + }); + + it("should set the PrtFeeSplitExtension state correctly", async () => { + expect(await prtFeeSplitExtension.prt()).to.eq(prt.address); + expect(await prtFeeSplitExtension.setToken()).to.eq(hyEth.address); + expect(await prtFeeSplitExtension.manager()).to.eq(baseManager.address); + expect(await prtFeeSplitExtension.streamingFeeModule()).to.eq(addresses.setFork.streamingFeeModule); + expect(await prtFeeSplitExtension.issuanceModule()).to.eq(addresses.setFork.debtIssuanceModuleV2_1); + expect(await prtFeeSplitExtension.operatorFeeSplit()).to.eq(prtFeeSplit); + expect(await prtFeeSplitExtension.operatorFeeRecipient()).to.eq(addresses.setFork.feeRecipient); + expect(await prtFeeSplitExtension.prtStakingPool()).to.eq(prtStakingPool.address); + }); + + it("should set the PrtFeeSplitExtension as an extension on the BaseManager", async () => { + expect(await baseManager.isExtension(prtFeeSplitExtension.address)); + }); + + it("should set the PrtStakingPool state correctly", async () => { + expect(await prtStakingPool.decimals()).to.eq(18); + expect(await prtStakingPool.feeSplitExtension()).to.eq(prtFeeSplitExtension.address); + expect(await prtStakingPool.prt()).to.eq(prt.address); + expect(await prtStakingPool.name()).to.eq(prtStakingPoolName); + expect(await prtStakingPool.symbol()).to.eq(prtStakingPoolSymbol); + expect(await prtStakingPool.setToken()).to.eq(hyEth.address); + expect(await prtStakingPool.totalSupply()).to.eq(0); + }); + + context("When the PRTs are distributed and staked", () => { + const bobPrtAmount = ether(1000); + const alicePrtAmount = ether(250); + const carolPrtAmount = ether(500); + + before(async () => { + await prt.connect(owner.wallet).transfer(bob.address, bobPrtAmount); + await prt.connect(owner.wallet).transfer(alice.address, alicePrtAmount); + await prt.connect(owner.wallet).transfer(carol.address, carolPrtAmount); + + await prt.connect(bob.wallet).approve(prtStakingPool.address, bobPrtAmount); + await prt.connect(alice.wallet).approve(prtStakingPool.address, alicePrtAmount); + await prt.connect(carol.wallet).approve(prtStakingPool.address, carolPrtAmount); + + await prtStakingPool.connect(bob.wallet).stake(bobPrtAmount); + await prtStakingPool.connect(alice.wallet).stake(alicePrtAmount); + await prtStakingPool.connect(carol.wallet).stake(carolPrtAmount); + }); + + it("should set the pre snapshot balances correctly", async () => { + const totalPrtAmount = bobPrtAmount.add(alicePrtAmount).add(carolPrtAmount); + expect(await prt.balanceOf(prtStakingPool.address)).to.eq(totalPrtAmount); + expect(await prtStakingPool.balanceOf(bob.address)).to.eq(bobPrtAmount); + expect(await prtStakingPool.balanceOf(alice.address)).to.eq(alicePrtAmount); + expect(await prtStakingPool.balanceOf(carol.address)).to.eq(carolPrtAmount); + expect(await prtStakingPool.totalSupply()).to.eq(totalPrtAmount); + }); + + it("should have no rewards and currentId 0", async () => { + expect(await hyEth.balanceOf(prtStakingPool.address)).to.eq(0); + expect(await prtStakingPool.getCurrentId()).to.eq(0); + }); + + context("When the first snapshot is taken", () => { + before(async () => { + await prtFeeSplitExtension.connect(operator).accrueFeesAndDistribute(); + }); + + it("should accrue fees and increment the snapshot id", async () => { + expect(await hyEth.balanceOf(prtStakingPool.address)).to.gt(0); + expect(await prtStakingPool.getCurrentId()).to.eq(1); + }); + + it("should allow bob and alice to claim proportional rewards", async () => { + // Bob and Alice claim after snapshot 1, carol does not claim + + const accruedFees = await prtStakingPool.accrueSnapshots(0); + const totalStakedPrtAmount = bobPrtAmount.add(alicePrtAmount).add(carolPrtAmount); + + const bobSetTokenBalanceBefore = await hyEth.balanceOf(bob.address); + const aliceSetTokenBalanceBefore = await hyEth.balanceOf(alice.address); + + const expectedBobRewards = accruedFees.mul(bobPrtAmount).div(totalStakedPrtAmount); + const expectedAliceRewards = accruedFees.mul(alicePrtAmount).div(totalStakedPrtAmount); + + const bobPendingRewardsBefore = await prtStakingPool.getPendingRewards(bob.address); + const alicePendingRewardsBefore = await prtStakingPool.getPendingRewards(alice.address); + + expect(bobPendingRewardsBefore).to.eq(expectedBobRewards); + expect(alicePendingRewardsBefore).to.eq(expectedAliceRewards); + + expect(bobSetTokenBalanceBefore).to.eq(0); + expect(aliceSetTokenBalanceBefore).to.eq(0); + + await prtStakingPool.connect(bob.wallet).claim(); + await prtStakingPool.connect(alice.wallet).claim(); + + const bobSetTokenBalanceAfter = await hyEth.balanceOf(bob.address); + const aliceSetTokenBalanceAfter = await hyEth.balanceOf(alice.address); + + const bobPendingRewardsAfter = await prtStakingPool.getPendingRewards(bob.address); + const alicePendingRewardsAfter = await prtStakingPool.getPendingRewards(alice.address); + + expect(bobPendingRewardsAfter).to.eq(0); + expect(alicePendingRewardsAfter).to.eq(0); + + expect(bobSetTokenBalanceAfter).to.eq(expectedBobRewards); + expect(aliceSetTokenBalanceAfter).to.eq(expectedAliceRewards); + }); + + context("When the second snapshot is taken", () => { + before(async () => { + await increaseTimeAsync(ONE_MONTH_IN_SECONDS); + await prtFeeSplitExtension.connect(operator).accrueFeesAndDistribute(); + }); + + it("should increment the snapshot id", async () => { + expect(await prtStakingPool.getCurrentId()).to.eq(2); + }); + + it("should allow bob, alice, and carol to claim proportional rewards", async () => { + // Bob and Alice claim again, carol claims for the first time + + const accruedFeesSnapshotOne = await prtStakingPool.accrueSnapshots(0); + const accruedFeesSnapshotTwo = await prtStakingPool.accrueSnapshots(1); + const totalStakedPrtAmount = bobPrtAmount.add(alicePrtAmount).add(carolPrtAmount); + + const bobSetTokenBalanceBefore = await hyEth.balanceOf(bob.address); + const aliceSetTokenBalanceBefore = await hyEth.balanceOf(alice.address); + const carolSetTokenBalanceBefore = await hyEth.balanceOf(carol.address); + + const expectedBobRewards = accruedFeesSnapshotTwo.mul(bobPrtAmount).div(totalStakedPrtAmount); + const expectedAliceRewards = accruedFeesSnapshotTwo.mul(alicePrtAmount).div(totalStakedPrtAmount); + + const expectedCarolSnapshotOneRewards = accruedFeesSnapshotOne.mul(carolPrtAmount).div(totalStakedPrtAmount); + const expectedCarolSnapshotTwoRewards = accruedFeesSnapshotTwo.mul(carolPrtAmount).div(totalStakedPrtAmount); + const expectedCarolRewards = expectedCarolSnapshotOneRewards.add(expectedCarolSnapshotTwoRewards); + + const bobPendingRewardsBefore = await prtStakingPool.getPendingRewards(bob.address); + const alicePendingRewardsBefore = await prtStakingPool.getPendingRewards(alice.address); + const carolPendingRewardsBefore = await prtStakingPool.getPendingRewards(carol.address); + + expect(bobPendingRewardsBefore).to.eq(expectedBobRewards); + expect(alicePendingRewardsBefore).to.eq(expectedAliceRewards); + expect(carolPendingRewardsBefore).to.eq(expectedCarolRewards); + + expect(bobSetTokenBalanceBefore).to.gt(0); + expect(aliceSetTokenBalanceBefore).to.gt(0); + expect(carolSetTokenBalanceBefore).to.eq(0); + + await prtStakingPool.connect(bob.wallet).claim(); + await prtStakingPool.connect(alice.wallet).claim(); + await prtStakingPool.connect(carol.wallet).claim(); + + const bobSetTokenBalanceAfter = await hyEth.balanceOf(bob.address); + const aliceSetTokenBalanceAfter = await hyEth.balanceOf(alice.address); + const carolSetTokenBalanceAfter = await hyEth.balanceOf(carol.address); + + const bobPendingRewardsAfter = await prtStakingPool.getPendingRewards(bob.address); + const alicePendingRewardsAfter = await prtStakingPool.getPendingRewards(alice.address); + const carolPendingRewardsAfter = await prtStakingPool.getPendingRewards(carol.address); + + expect(bobPendingRewardsAfter).to.eq(0); + expect(alicePendingRewardsAfter).to.eq(0); + expect(carolPendingRewardsAfter).to.eq(0); + + expect(bobSetTokenBalanceAfter).to.eq(expectedBobRewards.add(bobSetTokenBalanceBefore)); + expect(aliceSetTokenBalanceAfter).to.eq(expectedAliceRewards.add(aliceSetTokenBalanceBefore)); + expect(carolSetTokenBalanceAfter).to.eq(expectedCarolRewards); + }); + + context("When the third snapshot is taken", () => { + before(async () => { + await increaseTimeAsync(ONE_MONTH_IN_SECONDS); + + // Bob unstakes right before the third snapshot + await prtStakingPool.connect(bob.wallet).unstake(bobPrtAmount); + + await prtFeeSplitExtension.connect(operator).accrueFeesAndDistribute(); + }); + + it("should increment the snapshot id", async () => { + expect(await prtStakingPool.getCurrentId()).to.eq(3); + }); + + it("should allow bob, alice, and carol to claim proportional rewards", async () => { + // Alice and carol claim again, bob cannot claim + + const accruedFeesSnapshotThree = await prtStakingPool.accrueSnapshots(2); + const totalStakedPrtAmount = alicePrtAmount.add(carolPrtAmount); + + const bobSetTokenBalanceBefore = await hyEth.balanceOf(bob.address); + const aliceSetTokenBalanceBefore = await hyEth.balanceOf(alice.address); + const carolSetTokenBalanceBefore = await hyEth.balanceOf(carol.address); + + const expectedAliceRewards = accruedFeesSnapshotThree.mul(alicePrtAmount).div(totalStakedPrtAmount); + const expectedCarolRewards = accruedFeesSnapshotThree.mul(carolPrtAmount).div(totalStakedPrtAmount); + + const bobPendingRewardsBefore = await prtStakingPool.getPendingRewards(bob.address); + const alicePendingRewardsBefore = await prtStakingPool.getPendingRewards(alice.address); + const carolPendingRewardsBefore = await prtStakingPool.getPendingRewards(carol.address); + + expect(bobPendingRewardsBefore).to.eq(0); + expect(alicePendingRewardsBefore).to.eq(expectedAliceRewards); + expect(carolPendingRewardsBefore).to.eq(expectedCarolRewards); + + expect(bobSetTokenBalanceBefore).to.gt(0); + expect(aliceSetTokenBalanceBefore).to.gt(0); + expect(carolSetTokenBalanceBefore).to.gt(0); + + await prtStakingPool.connect(alice.wallet).claim(); + await prtStakingPool.connect(carol.wallet).claim(); + + const aliceSetTokenBalanceAfter = await hyEth.balanceOf(alice.address); + const carolSetTokenBalanceAfter = await hyEth.balanceOf(carol.address); + + const alicePendingRewardsAfter = await prtStakingPool.getPendingRewards(alice.address); + const carolPendingRewardsAfter = await prtStakingPool.getPendingRewards(carol.address); + + expect(alicePendingRewardsAfter).to.eq(0); + expect(carolPendingRewardsAfter).to.eq(0); + + expect(aliceSetTokenBalanceAfter).to.eq(expectedAliceRewards.add(aliceSetTokenBalanceBefore)); + expect(carolSetTokenBalanceAfter).to.eq(expectedCarolRewards.add(carolSetTokenBalanceBefore)); + }); + }); + }); + }); + }); + }); + }); +} diff --git a/test/staking/prtStakingPool.spec.ts b/test/staking/prtStakingPool.spec.ts new file mode 100644 index 00000000..0a440d8a --- /dev/null +++ b/test/staking/prtStakingPool.spec.ts @@ -0,0 +1,664 @@ +import "module-alias/register"; + +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ONE, THREE, TWO, ZERO } from "@utils/constants"; +import { PrtStakingPool, StandardTokenMock } from "@utils/contracts/index"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getSetFixture, + getWaffleExpect, + getRandomAccount, + getRandomAddress +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; +import { BigNumber } from "ethers"; + +const expect = getWaffleExpect(); + +describe.only("PrtStakingPool", () => { + let owner: Account; + let bob: Account; + let alice: Account; + let carol: Account; + let feeSplitExtension: Account; + + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + + let setToken: StandardTokenMock; + let prt: StandardTokenMock; + let prtStakingPool: PrtStakingPool; + + before(async () => { + [ + owner, + bob, + alice, + carol, + feeSplitExtension, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSetFixture(owner.address); + await setV2Setup.initialize(); + + setToken = await deployer.mocks.deployStandardTokenMock(owner.address, 18); + prt = await deployer.token.deployPrt( + "PRT", + "PRT", + setToken.address, + owner.address, + ether(1000000) + ); + + prtStakingPool = await deployer.staking.deployPrtStakingPool( + "PRT Staking Pool", + "PRT-POOL", + prt.address, + feeSplitExtension.address + ); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectName: string; + let subjectSymbol: string; + let subjectSetToken: Address; + let subjectPrt: Address; + let subjectFeeSplitExtension: Address; + + beforeEach(async () => { + subjectName = "PRT Staking Pool"; + subjectSymbol = "PRT-POOL"; + subjectSetToken = setToken.address; + subjectPrt = prt.address; + subjectFeeSplitExtension = setToken.address; + }); + + async function subject(): Promise { + return await deployer.staking.deployPrtStakingPool( + subjectName, + subjectSymbol, + subjectPrt, + subjectFeeSplitExtension + ); + } + + it("should set the correct name, symbol, and decimals", async () => { + const retrievedPrtStakingPool = await subject(); + + const actualName = await retrievedPrtStakingPool.name(); + expect(actualName).to.eq(subjectName); + + const actualSymbol = await retrievedPrtStakingPool.symbol(); + expect(actualSymbol).to.eq(subjectSymbol); + + const actualDecimals = await retrievedPrtStakingPool.decimals(); + expect(actualDecimals).to.eq(18); + }); + + it("should set the correct setToken address", async () => { + const retrievedPrtStakingPool = await subject(); + + const actualSetToken = await retrievedPrtStakingPool.setToken(); + expect(actualSetToken).to.eq(subjectSetToken); + }); + + it("should set the correct prt address", async () => { + const retrievedPrtStakingPool = await subject(); + + const actualPrt = await retrievedPrtStakingPool.prt(); + expect(actualPrt).to.eq(subjectPrt); + }); + + it("should set the correct FeeSplitExtension address", async () => { + const retrievedPrtStakingPool = await subject(); + + const actualFeeSplitExtension = await retrievedPrtStakingPool.feeSplitExtension(); + expect(actualFeeSplitExtension).to.eq(subjectFeeSplitExtension); + }); + }); + + describe("#setFeeSplitExtension", async () => { + let subjectNewFeeSplitExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectNewFeeSplitExtension = await getRandomAddress(); + subjectCaller = owner; + }); + + async function subject(): Promise { + return prtStakingPool.connect(subjectCaller.wallet).setFeeSplitExtension(subjectNewFeeSplitExtension); + } + + it("should set the new FeeSplitExtension", async () => { + await subject(); + const actualFeeSplitExtension = await prtStakingPool.feeSplitExtension(); + expect(actualFeeSplitExtension).to.eq(subjectNewFeeSplitExtension); + }); + + it("should emit the correct FeeSplitExtensionChanged event", async () => { + await expect(subject()).to.emit(prtStakingPool, "FeeSplitExtensionChanged").withArgs(subjectNewFeeSplitExtension); + }); + + describe("when the caller is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + }); + + describe("#stake", async () => { + let subjectAmount: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + const amount = ether(1); + + await prt.connect(owner.wallet).transfer(bob.address, amount); + await prt.connect(bob.wallet).approve(prtStakingPool.address, amount); + + subjectAmount = amount; + subjectCaller = bob; + }); + + async function subject(): Promise { + return prtStakingPool.connect(subjectCaller.wallet).stake(subjectAmount); + } + + it("should transfer PRTs from the staker to the PrtStakingPool", async () => { + const poolPrtBalanceBefore = await prt.balanceOf(prtStakingPool.address); + const holderPrtBalanceBefore = await prt.balanceOf(bob.address); + + await subject(); + + const poolPrtBalanceAfter = await prt.balanceOf(prtStakingPool.address); + const holderPrtBalanceAfter = await prt.balanceOf(bob.address); + + expect(poolPrtBalanceAfter).to.eq(poolPrtBalanceBefore.add(subjectAmount)); + expect(holderPrtBalanceAfter).to.eq(holderPrtBalanceBefore.sub(subjectAmount)); + }); + + it("should mint StakedPRTs for the staker", async () => { + const poolTotalSupplyBefore = await prtStakingPool.totalSupply(); + const holderStakedPrtBalanceBefore = await prtStakingPool.balanceOf(bob.address); + + await subject(); + + const poolTotalSupplyAfter = await prtStakingPool.totalSupply(); + const holderStakedPrtBalanceAfter = await prtStakingPool.balanceOf(bob.address); + + expect(poolTotalSupplyAfter).to.eq(poolTotalSupplyBefore.add(subjectAmount)); + expect(holderStakedPrtBalanceAfter).to.eq(holderStakedPrtBalanceBefore.add(subjectAmount)); + }); + + it("should emit the correct PRT Staking Pool Transfer event", async () => { + await expect(subject()).to.emit(prtStakingPool, "Transfer").withArgs(ADDRESS_ZERO, bob.address, subjectAmount); + }); + + describe("when the amount is 0", async () => { + beforeEach(async () => { + subjectAmount = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot stake 0"); + }); + }); + }); + + describe("#unstake", async () => { + let subjectAmount: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + const amount = ether(1); + + await prt.connect(owner.wallet).transfer(bob.address, amount); + await prt.connect(bob.wallet).approve(prtStakingPool.address, amount); + await prtStakingPool.connect(bob.wallet).stake(amount); + + subjectAmount = amount; + subjectCaller = bob; + }); + + async function subject(): Promise { + return prtStakingPool.connect(subjectCaller.wallet).unstake(subjectAmount); + } + + it("should transfer PRTs from the PrtStakingPool to the staker", async () => { + const poolPrtBalanceBefore = await prt.balanceOf(prtStakingPool.address); + const holderPrtBalanceBefore = await prt.balanceOf(bob.address); + + await subject(); + + const poolPrtBalanceAfter = await prt.balanceOf(prtStakingPool.address); + const holderPrtBalanceAfter = await prt.balanceOf(bob.address); + + expect(poolPrtBalanceAfter).to.eq(poolPrtBalanceBefore.sub(subjectAmount)); + expect(holderPrtBalanceAfter).to.eq(holderPrtBalanceBefore.add(subjectAmount)); + }); + + it("should burn StakedPRTs from the staker", async () => { + const poolTotalSupplyBefore = await prtStakingPool.totalSupply(); + const holderStakedPrtBalanceBefore = await prtStakingPool.balanceOf(bob.address); + + await subject(); + + const poolTotalSupplyAfter = await prtStakingPool.totalSupply(); + const holderStakedPrtBalanceAfter = await prtStakingPool.balanceOf(bob.address); + + expect(poolTotalSupplyAfter).to.eq(poolTotalSupplyBefore.sub(subjectAmount)); + expect(holderStakedPrtBalanceAfter).to.eq(holderStakedPrtBalanceBefore.sub(subjectAmount)); + }); + + it("should emit the correct PRT Staking Pool Transfer event", async () => { + await expect(subject()).to.emit(prtStakingPool, "Transfer").withArgs(bob.address, ADDRESS_ZERO, subjectAmount); + }); + + describe("when the amount is 0", async () => { + beforeEach(async () => { + subjectAmount = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot unstake 0"); + }); + }); + }); + + describe("#accrue", async () => { + let subjectAmount: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + const amount = ether(1); + + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, amount); + + subjectAmount = amount; + subjectCaller = feeSplitExtension; + }); + + async function subject(): Promise { + return prtStakingPool.connect(subjectCaller.wallet).accrue(subjectAmount); + } + + it("should transfer setToken from the FeeSplitExtension to the PrtStakingPool", async () => { + const poolSetTokenBalanceBefore = await setToken.balanceOf(prtStakingPool.address); + const feeSplitExtensionSetTokenBalanceBefore = await setToken.balanceOf(feeSplitExtension.address); + + await subject(); + + const poolSetTokenBalanceAfter = await setToken.balanceOf(prtStakingPool.address); + const feeSplitExtensionSetTokenBalanceAfter = await setToken.balanceOf(feeSplitExtension.address); + + expect(poolSetTokenBalanceAfter).to.eq(poolSetTokenBalanceBefore.add(subjectAmount)); + expect(feeSplitExtensionSetTokenBalanceAfter).to.eq(feeSplitExtensionSetTokenBalanceBefore.sub(subjectAmount)); + }); + + it("should push an accrue snapshot", async () => { + const accrueSnapshotsBefore = await prtStakingPool.getAccrueSnapshots(); + + await subject(); + + const accrueSnapshotsAfter = await prtStakingPool.getAccrueSnapshots(); + + expect(accrueSnapshotsAfter.length).to.eq(accrueSnapshotsBefore.length + 1); + expect(accrueSnapshotsAfter[accrueSnapshotsAfter.length - 1]).to.eq(subjectAmount); + }); + + describe("when the caller is not the FeeSplitExtension", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be FeeSplitExtension"); + }); + }); + }); + + describe("#claim", async () => { + let bobPrtAmount: BigNumber; + let alicePrtAmount: BigNumber; + let carolPrtAmount: BigNumber; + let snap1Amount: BigNumber; + let snap2Amount: BigNumber; + let snap3Amount: BigNumber; + + beforeEach(async () => { + // PRT balances (bob: 6 PRT, alice: 4 PRT, carol: 5 PRT) + bobPrtAmount = ether(6); + alicePrtAmount = ether(4); + carolPrtAmount = ether(5); + + // Snapshot rewards amounts (snap1: 1 SetToken, snap2: 1.5 SetToken, snap3: 2 SetToken) + snap1Amount = ether(1); + snap2Amount = ether(1.5); + snap3Amount = ether(2); + + // Fund bob, alice, and carol with PRT + await prt.connect(owner.wallet).transfer(bob.address, bobPrtAmount); + await prt.connect(owner.wallet).transfer(alice.address, alicePrtAmount); + await prt.connect(owner.wallet).transfer(carol.address, carolPrtAmount); + + // Approve staking pool to spend PRT + await prt.connect(bob.wallet).approve(prtStakingPool.address, bobPrtAmount); + await prt.connect(alice.wallet).approve(prtStakingPool.address, alicePrtAmount); + await prt.connect(carol.wallet).approve(prtStakingPool.address, carolPrtAmount); + + // Before snapshot 1, bob stakes PRTs + await prtStakingPool.connect(bob.wallet).stake(bobPrtAmount); + + // Take snapshot 1 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap1Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap1Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap1Amount); + + // After snapshot 1, alice stakes PRTs + await prtStakingPool.connect(alice.wallet).stake(alicePrtAmount); + + // After snapshot 1, carol stakes PRTs + await prtStakingPool.connect(carol.wallet).stake(carolPrtAmount); + + // Take snapshot 2 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap2Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap2Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap2Amount); + + // After snapshot 2, carol unstakes PRTs + await prtStakingPool.connect(carol.wallet).unstake(carolPrtAmount); + + // Take snapshot 3 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap3Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap3Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap3Amount); + + subjectCaller = bob; + }); + + async function subject(caller: Account): Promise { + return prtStakingPool.connect(caller.wallet).claim(); + } + + it("should transfer the pending SetToken rewards from the PrtStakingPool to the staker", async () => { + const prtStakingPoolSetTokenBalanceBefore = await setToken.balanceOf(prtStakingPool.address); + const prtHolderOneSetTokenBalanceBefore = await setToken.balanceOf(bob.address); + + const totalSupplySnap2 = await prtStakingPool.totalSupplyAt(TWO); + const totalSupplySnap3 = await prtStakingPool.totalSupplyAt(THREE); + + // (bob) who stakes before snapshot 1 and never unstakes + const expectedBobPendingRewards = snap1Amount.add( + bobPrtAmount.mul(snap2Amount).div(totalSupplySnap2) + ).add( + bobPrtAmount.mul(snap3Amount).div(totalSupplySnap3) + ); + + await subject(bob); + + const prtStakingPoolSetTokenBalanceAfter = await setToken.balanceOf(prtStakingPool.address); + const prtHolderOneSetTokenBalanceAfter = await setToken.balanceOf(bob.address); + + expect(prtStakingPoolSetTokenBalanceAfter).to.eq(prtStakingPoolSetTokenBalanceBefore.sub(expectedBobPendingRewards)); + expect(prtHolderOneSetTokenBalanceAfter).to.eq(prtHolderOneSetTokenBalanceBefore.add(expectedBobPendingRewards)); + }); + + it("should update the lastSnapshotId", async () => { + const lastSnapshotIdBefore = await prtStakingPool.lastSnapshotId(bob.address); + expect(lastSnapshotIdBefore).to.eq(0); + + await subject(bob); + + const lastSnapshotIdAfter = await prtStakingPool.lastSnapshotId(bob.address); + expect(lastSnapshotIdAfter).to.eq(3); + + const currentId = await prtStakingPool.getCurrentId(); + expect(lastSnapshotIdAfter).to.eq(currentId); + }); + + describe("when the user stakes after the first snapshot", async () => { + it("should still return pending rewards for staked snapshots", async () => { + const totalSupplySnap2 = await prtStakingPool.totalSupplyAt(TWO); + const totalSupplySnap3 = await prtStakingPool.totalSupplyAt(THREE); + + // (alice) who stakes after snapshot 1 and never unstakes + const expectedAlicePendingRewards = (alicePrtAmount.mul(snap2Amount).div(totalSupplySnap2)).add( + alicePrtAmount.mul(snap3Amount).div(totalSupplySnap3) + ); + + const aliceSetTokenBalanceBefore = await setToken.balanceOf(alice.address); + await subject(alice); + const aliceSetTokenBalanceAfter = await setToken.balanceOf(alice.address); + const actualAliceSetTokenChange = aliceSetTokenBalanceAfter.sub(aliceSetTokenBalanceBefore); + expect(actualAliceSetTokenChange).to.eq(expectedAlicePendingRewards); + }); + }); + + describe("when the user unstakes before the latest snapshot", async () => { + it("should still return pending rewards for staked snapshots", async () => { + const totalSupplySnap2 = await prtStakingPool.totalSupplyAt(TWO); + + // (carol) who stakes after snapshot 1 and unstakes after snapshot 2 + const expectedCarolPendingRewards = carolPrtAmount.mul(snap2Amount).div(totalSupplySnap2); + + const carolSetTokenBalanceBefore = await setToken.balanceOf(carol.address); + await subject(carol); + const carolSetTokenBalanceAfter = await setToken.balanceOf(carol.address); + const actualCarolSetTokenChange = carolSetTokenBalanceAfter.sub(carolSetTokenBalanceBefore); + expect(actualCarolSetTokenChange).to.eq(expectedCarolPendingRewards); + }); + }); + + describe("when there are no pending rewards", async () => { + it("should revert", async () => { + await expect(subject(owner)).to.be.revertedWith("No rewards to claim"); + }); + }); + + describe("when the rewards have been claimed", async () => { + beforeEach(async () => { + await prtStakingPool.connect(bob.wallet).claim(); + subjectCaller = bob; + }); + + it("should return 0", async () => { + await expect(subject(bob)).to.be.revertedWith("No rewards to claim"); + }); + }); + }); + + describe("#getSnapshotRewards", async () => { + let bobPrtAmount: BigNumber; + let alicePrtAmount: BigNumber; + let snap1Amount: BigNumber; + let snap2Amount: BigNumber; + + beforeEach(async () => { + // PRT balances (bob: 6 PRT, alice: 4 PRT) + bobPrtAmount = ether(6); + alicePrtAmount = ether(4); + + // Snapshot rewards amounts + snap1Amount = ether(1); + snap2Amount = ether(2); + + // Fund bob, alice, and carol with PRT + await prt.connect(owner.wallet).transfer(bob.address, bobPrtAmount); + await prt.connect(owner.wallet).transfer(alice.address, alicePrtAmount); + + // Approve staking pool to spend PRT + await prt.connect(bob.wallet).approve(prtStakingPool.address, bobPrtAmount); + await prt.connect(alice.wallet).approve(prtStakingPool.address, alicePrtAmount); + + // Before snapshot 1, bob stakes PRTs + await prtStakingPool.connect(bob.wallet).stake(bobPrtAmount); + + // Take snapshot 1 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap1Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap1Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap1Amount); + + // After snapshot 1, alice stakes PRTs + await prtStakingPool.connect(alice.wallet).stake(alicePrtAmount); + + // Take snapshot 2 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap2Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap2Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap2Amount); + }); + + async function subject(snapshotId: BigNumber, account: Address): Promise { + return prtStakingPool.getSnapshotRewards(snapshotId, account); + } + + describe("when the user deposits before the snapshot", async () => { + it("should accrue proportional rewards", async () => { + const snapshotRewards = await subject(ZERO, bob.address); + + expect(snapshotRewards).to.eq(snap1Amount); + }); + }); + + describe("when the user deposits after the snapshot", async () => { + it("should not accrue rewards", async () => { + const snapshotRewards = await subject(ZERO, alice.address); + + expect(snapshotRewards).to.eq(ZERO); + }); + }); + + describe("when multiple users deposit before the snapshot", async () => { + it("should both accrue proportional rewards", async () => { + const bobSnapshotRewards = await subject(ONE, bob.address); + const aliceSnapshotRewards = await subject(ONE, alice.address); + + const totalPrtAmount = bobPrtAmount.add(alicePrtAmount); + + expect(bobSnapshotRewards).to.eq(bobPrtAmount.mul(snap2Amount).div(totalPrtAmount)); + expect(aliceSnapshotRewards).to.eq(alicePrtAmount.mul(snap2Amount).div(totalPrtAmount)); + }); + }); + }); + + describe("#getPendingRewards", async () => { + let bobPrtAmount: BigNumber; + let alicePrtAmount: BigNumber; + let carolPrtAmount: BigNumber; + let snap1Amount: BigNumber; + let snap2Amount: BigNumber; + let snap3Amount: BigNumber; + + beforeEach(async () => { + // PRT balances (bob: 6 PRT, alice: 4 PRT, carol: 5 PRT) + bobPrtAmount = ether(6); + alicePrtAmount = ether(4); + carolPrtAmount = ether(5); + + // Snapshot rewards amounts (snap1: 1 SetToken, snap2: 1.5 SetToken, snap3: 2 SetToken) + snap1Amount = ether(1); + snap2Amount = ether(1.5); + snap3Amount = ether(2); + + // Fund bob, alice, and carol with PRT + await prt.connect(owner.wallet).transfer(bob.address, bobPrtAmount); + await prt.connect(owner.wallet).transfer(alice.address, alicePrtAmount); + await prt.connect(owner.wallet).transfer(carol.address, carolPrtAmount); + + // Approve staking pool to spend PRT + await prt.connect(bob.wallet).approve(prtStakingPool.address, bobPrtAmount); + await prt.connect(alice.wallet).approve(prtStakingPool.address, alicePrtAmount); + await prt.connect(carol.wallet).approve(prtStakingPool.address, carolPrtAmount); + + // Before snapshot 1, bob stakes PRTs + await prtStakingPool.connect(bob.wallet).stake(bobPrtAmount); + + // Take snapshot 1 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap1Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap1Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap1Amount); + + // After snapshot 1, alice stakes PRTs + await prtStakingPool.connect(alice.wallet).stake(alicePrtAmount); + + // After snapshot 1, carol stakes PRTs + await prtStakingPool.connect(carol.wallet).stake(carolPrtAmount); + + // Take snapshot 2 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap2Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap2Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap2Amount); + + // After snapshot 2, carol unstakes PRTs + await prtStakingPool.connect(carol.wallet).unstake(carolPrtAmount); + + // Take snapshot 3 + await setToken.connect(owner.wallet).transfer(feeSplitExtension.address, snap3Amount); + await setToken.connect(feeSplitExtension.wallet).approve(prtStakingPool.address, snap3Amount); + await prtStakingPool.connect(feeSplitExtension.wallet).accrue(snap3Amount); + }); + + async function subject(account: Address): Promise { + return prtStakingPool.getPendingRewards(account); + } + + it("should return the correct pending rewards", async () => { + const bobPendingRewards = await subject(bob.address); + const alicePendingRewards = await subject(alice.address); + const carolPendingRewards = await subject(carol.address); + + const totalSupplySnap2 = await prtStakingPool.totalSupplyAt(TWO); + const totalSupplySnap3 = await prtStakingPool.totalSupplyAt(THREE); + + // (bob) who stakes before snapshot 1 and never unstakes + const expectedBobPendingRewards = snap1Amount.add( + bobPrtAmount.mul(snap2Amount).div(totalSupplySnap2) + ).add( + bobPrtAmount.mul(snap3Amount).div(totalSupplySnap3) + ); + + // (alice) who stakes after snapshot 1 and never unstakes + const expectedAlicePendingRewards = (alicePrtAmount.mul(snap2Amount).div(totalSupplySnap2)).add( + alicePrtAmount.mul(snap3Amount).div(totalSupplySnap3) + ); + + // (carol) who stakes after snapshot 1 and unstakes after snapshot 2 + const expectedCarolPendingRewards = carolPrtAmount.mul(snap2Amount).div(totalSupplySnap2); + + expect(bobPendingRewards).to.eq(expectedBobPendingRewards); + expect(alicePendingRewards).to.eq(expectedAlicePendingRewards); + expect(carolPendingRewards).to.eq(expectedCarolPendingRewards); + }); + + describe("when the rewards have been claimed", async () => { + beforeEach(async () => { + await prtStakingPool.connect(bob.wallet).claim(); + }); + + it("should return 0", async () => { + const pendingRewards = await subject(bob.address); + expect(pendingRewards).to.eq(ZERO); + }); + }); + + describe("when the user never staked", async () => { + it("should return 0", async () => { + const pendingRewards = await subject(await getRandomAddress()); + expect(pendingRewards).to.eq(ZERO); + }); + }); + }); +}); diff --git a/test/token/prt.spec.ts b/test/token/prt.spec.ts new file mode 100644 index 00000000..34cc0be9 --- /dev/null +++ b/test/token/prt.spec.ts @@ -0,0 +1,57 @@ +import "module-alias/register"; + +import { Account } from "@utils/types"; +import { Prt } from "@utils/contracts/index"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getWaffleExpect, +} from "@utils/index"; +import { StandardTokenMock } from "@typechain/StandardTokenMock"; + +const expect = getWaffleExpect(); + +describe.only("Prt", async () => { + const prtName = "High Yield ETH Index PRT Token"; + const prtSymbol = "prtHyETH"; + const prtSupply = ether(10_000); + + let owner: Account; + let deployer: DeployHelper; + let setToken: StandardTokenMock; + + before(async () => { + [ owner ] = await getAccounts(); + deployer = new DeployHelper(owner.wallet); + setToken = await deployer.mocks.deployStandardTokenMock(owner.address, 18); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + async function subject(): Promise { + return deployer.token.deployPrt( + prtName, + prtSymbol, + setToken.address, + owner.address, + prtSupply + ); + } + + it("should set the state variables correctly", async () => { + const prt = await subject(); + expect(await prt.decimals()).to.eq(18); + expect(await prt.totalSupply()).to.eq(prtSupply); + expect(await prt.name()).to.eq(prtName); + expect(await prt.symbol()).to.eq(prtSymbol); + }); + + it("should distribute the PRT to the owner", async () => { + const prt = await subject(); + expect(await prt.balanceOf(owner.address)).to.eq(prtSupply); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 8d526249..0dc8dbcd 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -69,3 +69,6 @@ export { GlobalAuctionRebalanceExtension } from "../../typechain/GlobalAuctionRe export { GlobalOptimisticAuctionRebalanceExtension } from "../../typechain/GlobalOptimisticAuctionRebalanceExtension"; export { OptimisticAuctionRebalanceExtensionV1 } from "../../typechain/OptimisticAuctionRebalanceExtensionV1"; export { OptimisticOracleV3Mock } from "../../typechain/OptimisticOracleV3Mock"; +export { PrtStakingPool } from "../../typechain/PrtStakingPool"; +export { PrtFeeSplitExtension } from "../../typechain/PrtFeeSplitExtension"; +export { Prt } from "../../typechain/Prt"; diff --git a/utils/deploys/deployExtensions.ts b/utils/deploys/deployExtensions.ts index 9ea93e8e..e8928a77 100644 --- a/utils/deploys/deployExtensions.ts +++ b/utils/deploys/deployExtensions.ts @@ -23,6 +23,7 @@ import { FlashMintPerp, FlexibleLeverageStrategyExtension, FeeSplitExtension, + PrtFeeSplitExtension, GIMExtension, GovernanceExtension, MigrationExtension, @@ -54,6 +55,7 @@ import { FlashMintWrapped__factory } from "../../typechain/factories/FlashMintWr import { ExchangeIssuanceZeroEx__factory } from "../../typechain/factories/ExchangeIssuanceZeroEx__factory"; import { FlashMintPerp__factory } from "../../typechain/factories/FlashMintPerp__factory"; import { FeeSplitExtension__factory } from "../../typechain/factories/FeeSplitExtension__factory"; +import { PrtFeeSplitExtension__factory } from "../../typechain/factories/PrtFeeSplitExtension__factory"; import { FlexibleLeverageStrategyExtension__factory } from "../../typechain/factories/FlexibleLeverageStrategyExtension__factory"; import { GIMExtension__factory } from "../../typechain/factories/GIMExtension__factory"; import { GovernanceExtension__factory } from "../../typechain/factories/GovernanceExtension__factory"; @@ -87,6 +89,24 @@ export default class DeployExtensions { ); } + public async deployPrtFeeSplitExtension( + manager: Address, + streamingFeeModule: Address, + debtIssuanceModule: Address, + operatorFeeSplit: BigNumber, + operatorFeeRecipient: Address, + prt: Address, + ): Promise { + return await new PrtFeeSplitExtension__factory(this._deployerSigner).deploy( + manager, + streamingFeeModule, + debtIssuanceModule, + operatorFeeSplit, + operatorFeeRecipient, + prt, + ); + } + public async deployStreamingFeeSplitExtension( manager: Address, streamingFeeModule: Address, diff --git a/utils/deploys/deployStaking.ts b/utils/deploys/deployStaking.ts index 5c485f9d..8d8d94e5 100644 --- a/utils/deploys/deployStaking.ts +++ b/utils/deploys/deployStaking.ts @@ -3,6 +3,8 @@ import { Address } from "../types"; import { StakingRewardsV2 } from "../contracts/index"; import { StakingRewardsV2__factory } from "../../typechain/factories/StakingRewardsV2__factory"; +import { PrtStakingPool } from "../contracts/index"; +import { PrtStakingPool__factory } from "../../typechain/factories/PrtStakingPool__factory"; export default class DeployStaking { private _deployerSigner: Signer; @@ -24,4 +26,18 @@ export default class DeployStaking { duration ); } -} \ No newline at end of file + + public async deployPrtStakingPool( + name: string, + symbol: string, + prt: Address, + feeSplitExtension: Address + ): Promise { + return await new PrtStakingPool__factory(this._deployerSigner).deploy( + name, + symbol, + prt, + feeSplitExtension + ); + } +} diff --git a/utils/deploys/deployToken.ts b/utils/deploys/deployToken.ts index 72da42db..2783fafe 100644 --- a/utils/deploys/deployToken.ts +++ b/utils/deploys/deployToken.ts @@ -16,6 +16,8 @@ import { Vesting__factory } from "../../typechain/factories/Vesting__factory"; import { OtcEscrow__factory } from "../../typechain/factories/OtcEscrow__factory"; import { FTCVesting__factory } from "../../typechain/factories/FTCVesting__factory"; import { IndexPowah__factory } from "@typechain/factories/IndexPowah__factory"; +import { Prt } from "@typechain/Prt"; +import { Prt__factory } from "@typechain/factories/Prt__factory"; export default class DeployToken { private _deployerSigner: Signer; @@ -118,4 +120,20 @@ export default class DeployToken { vesting, ); } + + public async deployPrt( + name: Address, + symbol: Address, + setToken: Address, + distributor: Address, + totalSupply: BigNumber, + ): Promise { + return await new Prt__factory(this._deployerSigner).deploy( + name, + symbol, + setToken, + distributor, + totalSupply, + ); + } }