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..74afc304 --- /dev/null +++ b/contracts/adapters/PrtFeeSplitExtension.sol @@ -0,0 +1,228 @@ +/* + 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. Includes an optional allow list and timelock on accrue function. + */ +contract PrtFeeSplitExtension is FeeSplitExtension { + using Address for address; + using PreciseUnitMath for uint256; + using SafeMath for uint256; + + /* ============ Events ============ */ + + event AnyoneAccrueUpdated(bool isAnyoneAllowedToAccrue); + event AccruerStatusUpdated(address indexed accruer, bool isAccruerAllowed); + event OperatorFeeSplitUpdated(uint256 newFeeSplit); + event PrtFeesDistributed( + address indexed operatorFeeRecipient, + address indexed prtStakingPool, + uint256 operatorTake, + uint256 prtTake + ); + event PrtStakingPoolUpdated(address newPrtStakingPool); + + /* ============ Immutables ============ */ + + IPrt public immutable prt; + + /* ============ State Variables ============ */ + + bool public isAnyoneAllowedToAccrue; + address[] accrueAllowList; + mapping(address => bool) public accrueAllowMap; + IPrtStakingPool public prtStakingPool; + + /* ============ Modifiers ============ */ + + modifier onlyAllowedAccruer() { + require(_isAllowedAccruer(msg.sender), "Not allowed to accrue"); + _; + } + + /* ============ 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 MUTUAL UPGRADE: 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 + mutualUpgrade(manager.operator(), manager.methodologist()) + { + require(address(_prtStakingPool) != address(0), "Zero address not valid"); + require(_prtStakingPool.distributor() == address(this), "PRT Staking Pool distributor must be this extension"); + require(_prtStakingPool.stakeToken() == address(prt), "PRT Staking Pool stake token must be PRT"); + require(_prtStakingPool.rewardToken() == address(manager.setToken()), "PRT Staking Pool reward token must be SetToken"); + prtStakingPool = _prtStakingPool; + emit PrtStakingPoolUpdated(address(_prtStakingPool)); + } + + /** + * @notice ONLY ALLOWED ACCRUER: 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 onlyAllowedAccruer { + require(address(prtStakingPool) != address(0), "PRT Staking Pool not set"); + + // 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 MUTUAL UPGRADE: 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 + mutualUpgrade(manager.operator(), manager.methodologist()) + { + require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); + operatorFeeSplit = _newFeeSplit; + emit OperatorFeeSplitUpdated(_newFeeSplit); + } + + /** + * @notice ONLY OPERATOR: Toggles the permission status of specified addresses to call the `accrueFeesAndDistribute()` function. + * @param _accruers An array of addresses whose accrue permission status is to be toggled. + * @param _statuses An array of booleans indicating the new accrue permission status for each corresponding address in `_accruers`. + */ + function setAccruersStatus( + address[] memory _accruers, + bool[] memory _statuses + ) + external + onlyOperator + { + _accruers.validatePairsWithArray(_statuses); + for (uint256 i = 0; i < _accruers.length; i++) { + _updateAccrueAllowList(_accruers[i], _statuses[i]); + accrueAllowMap[_accruers[i]] = _statuses[i]; + emit AccruerStatusUpdated(_accruers[i], _statuses[i]); + } + } + + /** + * @notice ONLY OPERATOR: Toggles whether or not anyone is allowed to call the `accrueFeesAndDistribute()` function. + * If set to true, it bypasses the accrueAllowList, allowing any address to call the `accrueFeesAndDistribute()` function. + * @param _status A boolean indicating if anyone can accrue. + */ + function updateAnyoneAccrue(bool _status) + external + onlyOperator + { + isAnyoneAllowedToAccrue = _status; + emit AnyoneAccrueUpdated(_status); + } + + /** + * @notice Determines whether the given address is permitted to `accrueFeesAndDistribute()`. + * @param _accruer Address of the accruer. + * @return bool True if the given `_accruer` is permitted to accrue, false otherwise. + */ + function isAllowedAccruer(address _accruer) external view returns (bool) { + return _isAllowedAccruer(_accruer); + } + + /** + * @dev Retrieves the list of addresses that are permitted to `accrueFeesAndDistribute()`. + * @return address[] Array of addresses representing the allowed accruers. + */ + function getAllowedAccruers() external view returns (address[] memory) { + return accrueAllowList; + } + + + /* ============ Internal Functions ============ */ + + function _isAllowedAccruer(address _accruer) internal view returns (bool) { + return isAnyoneAllowedToAccrue || accrueAllowMap[_accruer]; + } + + function _updateAccrueAllowList(address _accruer, bool _status) internal { + if (_status && !accrueAllowList.contains(_accruer)) { + accrueAllowList.push(_accruer); + } else if(!_status && accrueAllowList.contains(_accruer)) { + accrueAllowList.removeStorage(_accruer); + } + } +} 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..dd691ab4 --- /dev/null +++ b/contracts/interfaces/IPrtStakingPool.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache License, Version 2.0 +pragma solidity ^0.6.10; + +interface IPrtStakingPool { + function accrue(uint256 _amount) external; + function distributor() external view returns (address); + function stakeToken() external view returns (address); + function rewardToken() external view returns (address); +} diff --git a/contracts/mocks/PrtStakingPoolMock.sol b/contracts/mocks/PrtStakingPoolMock.sol new file mode 100644 index 00000000..3040792f --- /dev/null +++ b/contracts/mocks/PrtStakingPoolMock.sol @@ -0,0 +1,38 @@ +/* + 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract PrtStakingPoolMock { + IERC20 public immutable rewardToken; + IERC20 public immutable stakeToken; + address public distributor; + + constructor(IERC20 _rewardToken, IERC20 _stakeToken, address _distributor) public { + rewardToken = _rewardToken; + stakeToken = _stakeToken; + distributor = _distributor; + } + + function accrue(uint256 _amount) external { + rewardToken.transferFrom(msg.sender, address(this), _amount); + } +} diff --git a/contracts/token/Prt.sol b/contracts/token/Prt.sol new file mode 100644 index 00000000..084a702e --- /dev/null +++ b/contracts/token/Prt.sol @@ -0,0 +1,53 @@ +/* + 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); + } +} diff --git a/test/adapters/prtFeeSplitExtension.spec.ts b/test/adapters/prtFeeSplitExtension.spec.ts new file mode 100644 index 00000000..5c361ba2 --- /dev/null +++ b/test/adapters/prtFeeSplitExtension.spec.ts @@ -0,0 +1,750 @@ +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, PrtStakingPoolMock } 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, + getRandomAddress +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; +import { BigNumber, ContractTransaction } from "ethers"; +import { solidityKeccak256 } from "ethers/lib/utils"; + +const expect = getWaffleExpect(); + +describe("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: PrtStakingPoolMock; + 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.mocks.deployPrtStakingPoolMock( + setToken.address, + prt.address, + feeExtension.address, + ); + }); + + describe("#updatePrtStakingPool", async () => { + let subjectNewPrtStakingPool: Address; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + subjectNewPrtStakingPool = prtStakingPool.address; + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension + .connect(caller.wallet) + .updatePrtStakingPool(subjectNewPrtStakingPool); + } + + context("when operator and methodologist both execute update", () => { + it("sets the new PRT Staking Pool", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const newPrtStakingPool = await feeExtension.prtStakingPool(); + expect(newPrtStakingPool).to.eq(subjectNewPrtStakingPool); + }); + + it("should emit a PrtStakingPoolUpdated event", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.emit(feeExtension, "PrtStakingPoolUpdated").withArgs(subjectNewPrtStakingPool); + }); + + describe("when the new PRT Staking Pool is address zero", async () => { + beforeEach(async () => { + subjectNewPrtStakingPool = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("Zero address not valid"); + }); + }); + + describe("when there is a FeeExtension mismatch", async () => { + beforeEach(async () => { + const wrongPrtPool = await deployer.mocks.deployPrtStakingPoolMock( + setToken.address, + prt.address, + ADDRESS_ZERO, // Use zero address instead of FeeExtension + ); + subjectNewPrtStakingPool = wrongPrtPool.address; + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("PRT Staking Pool distributor must be this extension"); + }); + }); + + describe("when there is a stakeToken mismatch", async () => { + beforeEach(async () => { + const wrongPrtPool = await deployer.mocks.deployPrtStakingPoolMock( + setToken.address, + ADDRESS_ZERO, // Use zero address instead of PRT + feeExtension.address, + ); + subjectNewPrtStakingPool = wrongPrtPool.address; + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("PRT Staking Pool stake token must be PRT"); + }); + }); + + describe("when there is a rewardToken mismatch", async () => { + beforeEach(async () => { + const wrongPrtPool = await deployer.mocks.deployPrtStakingPoolMock( + ADDRESS_ZERO, // Use zero address instead of SetToken + prt.address, + feeExtension.address, + ); + subjectNewPrtStakingPool = wrongPrtPool.address; + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("PRT Staking Pool reward token must be SetToken"); + }); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(subjectMethodologistCaller)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + 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).updateAnyoneAccrue(true); + + 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 () => { + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(methodologist.wallet).updatePrtStakingPool(prtStakingPool.address); + + 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 feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(methodologist.wallet).updatePrtStakingPool(prtStakingPool.address); + await expect(subject()).to.emit(feeExtension, "PrtFeesDistributed"); + }); + + describe("when PRT Staking Pool fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(methodologist.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(operator.wallet).updateFeeSplit(ether(1)); + await feeExtension.connect(methodologist.wallet).updateFeeSplit(ether(1)); + }); + + it("should not send fees to the PRT Staking Pool", async () => { + const preStakingPoolBalance = await setToken.balanceOf(prtStakingPool.address); + + await subject(); + + const postStakingPoolBalance = await setToken.balanceOf(prtStakingPool.address); + expect(postStakingPoolBalance.sub(preStakingPoolBalance)).to.eq(ZERO); + }); + }); + + describe("when operator fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(methodologist.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(operator.wallet).updateFeeSplit(ZERO); + await feeExtension.connect(methodologist.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 the PRT Staking Pool is not set", async () => { + it("should not revert", async () => { + await expect(subject()).to.be.revertedWith("PRT Staking Pool not set"); + }); + }); + + 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 () => { + await feeExtension.connect(operator.wallet).updatePrtStakingPool(prtStakingPool.address); + await feeExtension.connect(methodologist.wallet).updatePrtStakingPool(prtStakingPool.address); + + 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; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + subjectNewFeeSplit = ether(.5); + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeSplit(subjectNewFeeSplit); + } + + context("when operator and methodologist both execute update", () => { + it("should not accrue fees", async () => { + const operatorFeeRecipientBalanceBefore = await setToken.balanceOf(operatorFeeRecipient.address); + const prtStakingPoolBalanceBefore = await setToken.balanceOf(prtStakingPool.address); + + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + 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); + await subject(subjectMethodologistCaller); + + const actualFeeSplit = await feeExtension.operatorFeeSplit(); + + expect(actualFeeSplit).to.eq(subjectNewFeeSplit); + }); + + it("should emit a OperatorFeeSplitUpdated event", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.emit(feeExtension, "OperatorFeeSplitUpdated").withArgs(subjectNewFeeSplit); + }); + + describe("when fee splits is >100%", async () => { + beforeEach(async () => { + subjectNewFeeSplit = ether(1.1); + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("Fee must be less than 100%"); + }); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(subjectMethodologistCaller)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#isAllowedAccruer", async () => { + let subjectAccruer: Account; + + beforeEach(async () => { + await feeExtension.connect(operator.wallet).setAccruersStatus([owner.address], [true]); + + subjectAccruer = owner; + }); + + async function subject(): Promise { + return await feeExtension.isAllowedAccruer(subjectAccruer.address); + } + + it("should return true if the address is an allowed accruer", async () => { + const isAccruer = await subject(); + + expect(isAccruer).to.be.true; + }); + + it("should return false if the address is not an allowed accruer", async () => { + await feeExtension.connect(operator.wallet).setAccruersStatus([owner.address], [false]); + + const isAccruer = await subject(); + + expect(isAccruer).to.be.false; + }); + }); + + describe("#getAllowedAccruers", async () => { + let subjectAccruers: Address[]; + let subjectStatuses: boolean[]; + + beforeEach(async () => { + subjectAccruers = [operator.address, owner.address]; + subjectStatuses = [true, true]; + + await feeExtension.connect(operator.wallet).setAccruersStatus(subjectAccruers, subjectStatuses); + }); + + async function subject(): Promise { + return await feeExtension.getAllowedAccruers(); + } + + it("should return the addresses of the allowed accruers", async () => { + const allowedAccruers = await subject(); + + expect(allowedAccruers).to.deep.equal(subjectAccruers); + }); + + describe("when a accruer is removed", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).setAccruersStatus([operator.address], [false]); + }); + + it("should remove the accruer and maintain the list correctly", async () => { + const allowedAccruers = await subject(); + + expect(allowedAccruers).to.not.deep.equal(subjectAccruers); + expect(allowedAccruers).to.deep.equal([owner.address]); + }); + }); + }); + + describe("#setAccruersStatus", async () => { + let subjectAccruers: Address[]; + let subjectStatuses: boolean[]; + + let subjectCaller: Account; + + beforeEach(async () => { + subjectAccruers = [owner.address, await getRandomAddress(), await getRandomAddress()]; + subjectStatuses = [true, true, true]; + + subjectCaller = operator; + }); + + async function subject(): Promise { + return await feeExtension.connect(subjectCaller.wallet).setAccruersStatus( + subjectAccruers, + subjectStatuses + ); + } + + it("should set the accruer status to true for multiple accruers", async () => { + await subject(); + + const isAccruerOne = await feeExtension.isAllowedAccruer(subjectAccruers[0]); + const isAccruerTwo = await feeExtension.isAllowedAccruer(subjectAccruers[1]); + const isAccruerThree = await feeExtension.isAllowedAccruer(subjectAccruers[2]); + + expect(isAccruerOne).to.be.true; + expect(isAccruerTwo).to.be.true; + expect(isAccruerThree).to.be.true; + }); + + it("should emit an AccruerStatusUpdated event", async () => { + await expect(subject()).to.emit(feeExtension, "AccruerStatusUpdated").withArgs( + subjectAccruers[0], + true + ); + }); + + describe("when de-authorizing an accruer", async () => { + beforeEach(async () => { + await subject(); + subjectStatuses = [false, true, true]; + }); + + it("should set the accruer status to false for the de-authorized accruer", async () => { + const initialStatus = await feeExtension.isAllowedAccruer(subjectAccruers[0]); + expect(initialStatus).to.be.true; + + await subject(); + + const finalStatus = await feeExtension.isAllowedAccruer(subjectAccruers[0]); + expect(finalStatus).to.be.false; + }); + + it("should update the accruersHistory correctly", async () => { + const initialAccruers = await feeExtension.getAllowedAccruers(); + expect(initialAccruers).to.deep.equal(subjectAccruers); + + await subject(); + + const finalAccruers = await feeExtension.getAllowedAccruers(); + const expectedAccruers = subjectAccruers.slice(1); + + expect(expectedAccruers[0]).to.not.equal(expectedAccruers[1]); + expect(finalAccruers[0]).to.not.equal(finalAccruers[1]); + + expect(finalAccruers.includes(expectedAccruers[0])).to.be.true; + expect(finalAccruers.includes(expectedAccruers[1])).to.be.true; + }); + }); + + describe("when array lengths don't match", async () => { + beforeEach(async () => { + subjectStatuses = [false]; + }); + + it("should revert with 'Array length mismatch'", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when accruers are duplicated", async () => { + beforeEach(async () => { + subjectAccruers = [owner.address, owner.address, await getRandomAddress()]; + }); + + it("should revert with 'Cannot duplicate addresses'", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate addresses"); + }); + }); + + describe("when arrays are empty", async () => { + beforeEach(async () => { + subjectAccruers = []; + subjectStatuses = []; + }); + + it("should revert with 'Array length must be > 0'", async () => { + await expect(subject()).to.be.revertedWith("Array length must be > 0"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#updateAnyoneAccrue", async () => { + let subjectStatus: boolean; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + subjectStatus = true; + }); + + async function subject(): Promise { + return await feeExtension.connect(subjectCaller.wallet).updateAnyoneAccrue(subjectStatus); + } + + it("should set isAnyoneAllowedToAccrue to true", async () => { + await subject(); + + const isAnyoneAllowedToAccrue = await feeExtension.isAnyoneAllowedToAccrue(); + expect(isAnyoneAllowedToAccrue).to.be.true; + }); + + it("should emit an AnyoneAccrueUpdated event", async () => { + await expect(subject()).to.emit(feeExtension, "AnyoneAccrueUpdated").withArgs(true); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + }); +}); diff --git a/test/token/prt.spec.ts b/test/token/prt.spec.ts new file mode 100644 index 00000000..4cf0cd45 --- /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("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..2a01b411 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 { PrtStakingPoolMock } from "../../typechain/PrtStakingPoolMock"; +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/deployMocks.ts b/utils/deploys/deployMocks.ts index faf24229..4125de24 100644 --- a/utils/deploys/deployMocks.ts +++ b/utils/deploys/deployMocks.ts @@ -20,6 +20,7 @@ import { ModuleMock, BaseGlobalExtensionMock, MutualUpgradeV2Mock, + PrtStakingPoolMock, } from "../contracts/index"; import { BaseExtensionMock__factory } from "../../typechain/factories/BaseExtensionMock__factory"; @@ -49,6 +50,7 @@ import { AaveV2LendingPoolMock } from "@typechain/AaveV2LendingPoolMock"; import { FlashMintLeveragedCompMock } from "@typechain/FlashMintLeveragedCompMock"; import { FlashMintLeveragedCompMock__factory } from "@typechain/factories/FlashMintLeveragedCompMock__factory"; import { OptimisticOracleV3Mock__factory } from "@typechain/factories/OptimisticOracleV3Mock__factory"; +import { PrtStakingPoolMock__factory } from "@typechain/factories/PrtStakingPoolMock__factory"; export default class DeployMocks { private _deployerSigner: Signer; @@ -228,4 +230,16 @@ export default class DeployMocks { public async deployOptimisticOracleV3Mock() { return await new OptimisticOracleV3Mock__factory(this._deployerSigner).deploy(); } + + public async deployPrtStakingPoolMock( + setToken: Address, + prt: Address, + feeSplitExtension: Address, + ): Promise { + return await new PrtStakingPoolMock__factory(this._deployerSigner).deploy( + setToken, + prt, + feeSplitExtension, + ); + } } diff --git a/utils/deploys/deployStaking.ts b/utils/deploys/deployStaking.ts index 5c485f9d..ffbdb29c 100644 --- a/utils/deploys/deployStaking.ts +++ b/utils/deploys/deployStaking.ts @@ -24,4 +24,4 @@ export default class DeployStaking { duration ); } -} \ No newline at end of file +} 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, + ); + } }