diff --git a/contracts/interfaces/IAssessment.sol b/contracts/interfaces/IAssessment.sol index a367fb3b89..d9bce9046c 100644 --- a/contracts/interfaces/IAssessment.sol +++ b/contracts/interfaces/IAssessment.sol @@ -109,7 +109,7 @@ interface IAssessment { function getVoteCountOfAssessor(address assessor) external view returns (uint); - function votesOf(address user, uint id) external view + function votesOf(address user, uint voteIndex) external view returns (uint80 assessmentId, bool accepted, uint32 timestamp, uint96 stakedAmount); function stakeOf(address user) external view @@ -122,7 +122,7 @@ interface IAssessment { uint8 silentEndingPeriodInDays ); - function hasAlreadyVotedOn(address voter, uint pollId) external view returns (bool); + function hasAlreadyVotedOn(address voter, uint assessmentId) external view returns (bool); /* === MUTATIVE FUNCTIONS ==== */ @@ -131,6 +131,8 @@ interface IAssessment { function unstake(uint96 amount, address to) external; + function unstakeAllFor(address staker) external; + function withdrawRewards( address user, uint104 batchSize @@ -174,4 +176,21 @@ interface IAssessment { event FraudProcessed(uint assessmentId, address assessor, Poll poll); event FraudSubmitted(bytes32 root); + /* ========== ERRORS ========== */ + + error InvalidAmount(uint maxUnstakeAmount); + error StakeLockedForAssessment(uint lockupExpiry); + error StakeLockedForGovernance(uint lockupExpiry); + error NotMember(address nonMember); + error NoWithdrawableRewards(); + error InvalidMerkleProof(); + error OnlyTokenController(); + + // Votes + error AssessmentIdsVotesLengthMismatch(); + error AssessmentIdsIpfsLengthMismatch(); + error AlreadyVoted(); + error StakeRequired(); + error VotingClosed(); + error AcceptVoteRequired(); } diff --git a/contracts/interfaces/ICover.sol b/contracts/interfaces/ICover.sol index ae1965142e..df0c93b02f 100644 --- a/contracts/interfaces/ICover.sol +++ b/contracts/interfaces/ICover.sol @@ -173,4 +173,7 @@ interface ICover { error CoverNotYetExpired(uint coverId); error InsufficientCoverAmountAllocated(); error UnexpectedPoolId(); + + // TODO: remove me after the rewards update + error OnlySwapOperator(); } diff --git a/contracts/interfaces/IStakingPool.sol b/contracts/interfaces/IStakingPool.sol index 77f67da172..5d67cd7972 100644 --- a/contracts/interfaces/IStakingPool.sol +++ b/contracts/interfaces/IStakingPool.sol @@ -53,12 +53,20 @@ interface IStakingPool { uint128 rewardsShares; } + struct WithdrawTrancheContext { + uint _firstActiveTrancheId; + uint _accNxmPerRewardsShare; + uint managerLockedInGovernanceUntil; + bool withdrawStake; + bool withdrawRewards; + address destination; +} + function initialize( bool isPrivatePool, uint initialPoolFee, uint maxPoolFee, - uint _poolId, - string memory ipfsDescriptionHash + uint _poolId ) external; function processExpirations(bool updateUntilCurrentTimestamp) external; @@ -149,6 +157,11 @@ interface IStakingPool { uint reductionRatio ) external view returns (uint[] memory trancheCapacities); + function updateRewardsShares( + uint trancheId, + uint[] calldata tokenIds + ) external; + /* ========== EVENTS ========== */ event StakeDeposited(address indexed user, uint256 amount, uint256 trancheId, uint256 tokenId); @@ -159,8 +172,6 @@ interface IStakingPool { event PoolFeeChanged(address indexed manager, uint newFee); - event PoolDescriptionSet(string ipfsDescriptionHash); - event Withdraw(address indexed user, uint indexed tokenId, uint tranche, uint amountStakeWithdrawn, uint amountRewardsWithdrawn); event StakeBurned(uint amount); diff --git a/contracts/interfaces/IStakingProducts.sol b/contracts/interfaces/IStakingProducts.sol index bbec85a46d..3e034b399d 100644 --- a/contracts/interfaces/IStakingProducts.sol +++ b/contracts/interfaces/IStakingProducts.sol @@ -47,6 +47,8 @@ interface IStakingProducts { uint bumpedPriceUpdateTime ); + function getPoolManager(uint poolId) external view returns (address); + /* ============= PRICING FUNCTIONS ============= */ function getPremium( @@ -117,6 +119,12 @@ interface IStakingProducts { function changeStakingPoolFactoryOperator(address newOperator) external; + function setPoolMetadata(uint poolId, string memory ipfsHash) external; + + function getPoolMetadata(uint poolId) external view returns (string memory ipfsHash); + + function setInitialMetadata(string[] calldata ipfsHashes) external; + /* ============= EVENTS ============= */ event ProductUpdated(uint productId, uint8 targetWeight, uint96 targetPrice); @@ -143,4 +151,6 @@ interface IStakingProducts { error InvalidProductType(); error TargetPriceBelowGlobalMinPriceRatio(); + // IPFS + error IpfsHashRequired(); } diff --git a/contracts/interfaces/IStakingViewer.sol b/contracts/interfaces/IStakingViewer.sol index e7cde28447..0ff4b4fbf4 100644 --- a/contracts/interfaces/IStakingViewer.sol +++ b/contracts/interfaces/IStakingViewer.sol @@ -13,6 +13,7 @@ interface IStakingViewer { uint maxPoolFee; uint activeStake; uint currentAPY; + string metadataIpfsHash; } struct StakingProduct { diff --git a/contracts/interfaces/ISwapOperator.sol b/contracts/interfaces/ISwapOperator.sol index 75f89ecbe6..629fd64980 100644 --- a/contracts/interfaces/ISwapOperator.sol +++ b/contracts/interfaces/ISwapOperator.sol @@ -3,7 +3,11 @@ pragma solidity >=0.5.0; import "../external/cow/GPv2Order.sol"; -import "../interfaces/IPool.sol"; +import "../external/enzyme/IEnzymeFundValueCalculatorRouter.sol"; +import "./ICowSettlement.sol"; +import "./INXMMaster.sol"; +import "./IPool.sol"; +import "./IWeth.sol"; interface ISwapOperator { @@ -21,6 +25,28 @@ interface ISwapOperator { function orderInProgress() external view returns (bool); + function currentOrderUID() external view returns (bytes memory); + + /* ========== IMMUTABLES ========== */ + + function cowSettlement() external view returns (ICowSettlement); + + function cowVaultRelayer() external view returns (address); + + function master() external view returns (INXMMaster); + + function swapController() external view returns (address); + + function weth() external view returns (IWeth); + + function domainSeparator() external view returns (bytes32); + + function enzymeV4VaultProxyAddress() external view returns (address); + + function enzymeFundValueCalculatorRouter() external view returns (IEnzymeFundValueCalculatorRouter); + + function minPoolEth() external view returns (uint); + /* ==== MUTATIVE FUNCTIONS ==== */ function placeOrder(GPv2Order.Data calldata order, bytes calldata orderUID) external; diff --git a/contracts/interfaces/ITokenController.sol b/contracts/interfaces/ITokenController.sol index 243d636897..8052861cd9 100644 --- a/contracts/interfaces/ITokenController.sol +++ b/contracts/interfaces/ITokenController.sol @@ -24,6 +24,33 @@ interface ITokenController { uint96 deadline; } + struct WithdrawAssessment { + bool stake; + bool rewards; + } + + /// @notice The stake deposit / rewards in a staking pool that will be withdrawn. + /// @dev Call StakingViewer.getToken to get tokenId / trancheId information + /// @param tokenId The ID of the token stake deposit / rewards that will be withrawn. + /// @param trancheIds An array of tranche IDs representing the tranches where the stake was deposited. + struct StakingPoolDeposit { + uint tokenId; + uint[] trancheIds; + } + + /// @notice Represents the rewards distributed to a staking pool manager. + /// @dev Call StakingViewer.getManagerTokenRewardsByAddr to get poolId / trancheId information + /// @param poolId The ID of the pool managed by the manager. + /// @param trancheIds An array of tranche IDs representing the tranches where the manager rewards were distributed. + struct StakingPoolManagerReward { + uint poolId; + uint[] trancheIds; + } + + /* ========== VIEWS ========== */ + + function token() external view returns (INXMToken); + function coverInfo(uint id) external view returns ( uint16 claimCount, bool hasOpenClaim, @@ -31,6 +58,40 @@ interface ITokenController { uint96 requestedPayoutAmount ); + function getLockReasons(address _of) external view returns (bytes32[] memory reasons); + + function totalSupply() external view returns (uint); + + function totalBalanceOf(address _of) external view returns (uint amount); + + function totalBalanceOfWithoutDelegations(address _of) external view returns (uint amount); + + function getTokenPrice() external view returns (uint tokenPrice); + + function getPendingRewards(address member) external view returns (uint); + + function tokensLocked(address _of, bytes32 _reason) external view returns (uint256 amount); + + function getWithdrawableCoverNotes( + address coverOwner + ) external view returns ( + uint[] memory coverIds, + bytes32[] memory lockReasons, + uint withdrawableAmount + ); + + function getStakingPoolManager(uint poolId) external view returns (address manager); + + function getManagerStakingPools(address manager) external view returns (uint[] memory poolIds); + + function isStakingPoolManager(address member) external view returns (bool); + + function getStakingPoolOwnershipOffer(uint poolId) external view returns (address proposedManager, uint deadline); + + function stakingPoolNXMBalances(uint poolId) external view returns (uint128 rewards, uint128 deposits); + + /* ========== MUTATIVE FUNCTIONS ========== */ + function withdrawCoverNote( address _of, uint[] calldata _coverIds, @@ -53,26 +114,6 @@ interface ITokenController { function withdrawClaimAssessmentTokens(address[] calldata users) external; - function getLockReasons(address _of) external view returns (bytes32[] memory reasons); - - function totalSupply() external view returns (uint); - - function totalBalanceOf(address _of) external view returns (uint amount); - - function totalBalanceOfWithoutDelegations(address _of) external view returns (uint amount); - - function getTokenPrice() external view returns (uint tokenPrice); - - function token() external view returns (INXMToken); - - function getStakingPoolManager(uint poolId) external view returns (address manager); - - function getManagerStakingPools(address manager) external view returns (uint[] memory poolIds); - - function isStakingPoolManager(address member) external view returns (bool); - - function getStakingPoolOwnershipOffer(uint poolId) external view returns (address proposedManager, uint deadline); - function transferStakingPoolsOwnership(address from, address to) external; function assignStakingPoolManager(uint poolId, address manager) external; @@ -92,18 +133,4 @@ interface ITokenController { function withdrawNXMStakeAndRewards(address to, uint stakeToWithdraw, uint rewardsToWithdraw, uint poolId) external; function burnStakedNXM(uint amount, uint poolId) external; - - function stakingPoolNXMBalances(uint poolId) external view returns(uint128 rewards, uint128 deposits); - - function tokensLocked(address _of, bytes32 _reason) external view returns (uint256 amount); - - function getWithdrawableCoverNotes( - address coverOwner - ) external view returns ( - uint[] memory coverIds, - bytes32[] memory lockReasons, - uint withdrawableAmount - ); - - function getPendingRewards(address member) external view returns (uint); } diff --git a/contracts/mocks/SafeTracker/STMockSwapOperator.sol b/contracts/mocks/SafeTracker/STMockSwapOperator.sol index e174d30f3c..624a66ac08 100644 --- a/contracts/mocks/SafeTracker/STMockSwapOperator.sol +++ b/contracts/mocks/SafeTracker/STMockSwapOperator.sol @@ -2,47 +2,8 @@ pragma solidity ^0.8.18; -import "../../interfaces/ISwapOperator.sol"; +import "../generic/SwapOperatorGeneric.sol"; -contract STMockSwapOperator is ISwapOperator { +contract STMockSwapOperator is SwapOperatorGeneric { - function requestAsset(address, uint) external virtual pure { - revert("Unsupported"); - } - - function transferRequestedAsset(address, uint) external virtual pure { - revert("Unsupported"); - } - - function getDigest(GPv2Order.Data calldata) external virtual view returns (bytes32) { - revert("Unsupported"); - } - - function getUID(GPv2Order.Data calldata) external virtual view returns (bytes memory) { - revert("Unsupported"); - } - - function orderInProgress() external virtual pure returns (bool) { - revert("Unsupported"); - } - - function placeOrder(GPv2Order.Data calldata, bytes calldata) external virtual { - revert("Unsupported"); - } - - function closeOrder(GPv2Order.Data calldata) external virtual { - revert("Unsupported"); - } - - function swapEnzymeVaultShareForETH(uint, uint) external virtual { - revert("Unsupported"); - } - - function swapETHForEnzymeVaultShare(uint, uint) external virtual { - revert("Unsupported"); - } - - function recoverAsset(address, address) external virtual { - revert("Unsupported"); - } } diff --git a/contracts/mocks/disposables/DisposableTokenController.sol b/contracts/mocks/disposables/DisposableTokenController.sol index 1ac8f21718..d48acc71a6 100644 --- a/contracts/mocks/disposables/DisposableTokenController.sol +++ b/contracts/mocks/disposables/DisposableTokenController.sol @@ -11,8 +11,15 @@ contract DisposableTokenController is TokenController { address quotationDataAddress, address claimsRewardAddress, address stakingPoolFactoryAddress, - address tokenAddress - ) TokenController(quotationDataAddress, claimsRewardAddress, stakingPoolFactoryAddress, tokenAddress) {} + address tokenAddress, + address stakingNFTAddress + ) TokenController( + quotationDataAddress, + claimsRewardAddress, + stakingPoolFactoryAddress, + tokenAddress, + stakingNFTAddress + ) {} function initialize( address payable _masterAddress, diff --git a/contracts/mocks/generic/AssessmentGeneric.sol b/contracts/mocks/generic/AssessmentGeneric.sol index 4795373d62..7f925c0aaa 100644 --- a/contracts/mocks/generic/AssessmentGeneric.sol +++ b/contracts/mocks/generic/AssessmentGeneric.sol @@ -17,54 +17,58 @@ contract AssessmentGeneric is IAssessment { mapping(address => mapping(uint => bool)) public hasAlreadyVotedOn; function getAssessmentsCount() external virtual view returns (uint) { - revert("Unsupported"); + revert("getAssessmentsCount unsupported"); } function getPoll(uint) external virtual view returns (Poll memory) { - revert("Unsupported"); + revert("getPoll unsupported"); } - function getRewards(address) external pure returns (uint, uint, uint) { - revert("Unsupported"); + function getRewards(address) external virtual view returns (uint, uint, uint) { + revert("getRewards unsupported"); } function getVoteCountOfAssessor(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("getVoteCountOfAssessor unsupported"); } function stake(uint96) external pure { - revert("Unsupported"); + revert("stake unsupported"); } function unstake(uint96, address) external pure { - revert("Unsupported"); + revert("unstake unsupported"); + } + + function unstakeAllFor(address) external pure { + revert("unstakeFor unsupported"); } function withdrawRewards(address, uint104) external virtual returns (uint, uint) { - revert("Unsupported"); + revert("withdrawRewards unsupported"); } function withdrawRewardsTo(address, uint104) external virtual returns (uint, uint) { - revert("Unsupported"); + revert("withdrawRewardsTo unsupported"); } function startAssessment(uint, uint) external virtual returns (uint) { - revert("Unsupported"); + revert("startAssessment unsupported"); } function castVotes(uint[] calldata, bool[] calldata, string[] calldata, uint96) external virtual pure { - revert("Unsupported"); + revert("castVotes unsupported"); } function submitFraud(bytes32) external pure { - revert("Unsupported"); + revert("submitFraud unsupported"); } function processFraud(uint256, bytes32[] calldata, address, uint256, uint96, uint16, uint256) external pure { - revert("Unsupported"); + revert("processFraud unsupported"); } function updateUintParameters(UintParams[] calldata, uint[] calldata) external pure { - revert("Unsupported"); + revert("updateUintParameters unsupported"); } } diff --git a/contracts/mocks/generic/PooledStakingGeneric.sol b/contracts/mocks/generic/PooledStakingGeneric.sol index 4fb241b300..833385a8a5 100644 --- a/contracts/mocks/generic/PooledStakingGeneric.sol +++ b/contracts/mocks/generic/PooledStakingGeneric.sol @@ -7,50 +7,50 @@ import "../../interfaces/IPooledStaking.sol"; contract PooledStakingGeneric is IPooledStaking { function accumulateReward(address, uint) external virtual { - revert("Unsupported"); + revert("accumulateReward unsupported"); } function pushBurn(address, uint) external virtual { - revert("Unsupported"); + revert("pushBurn unsupported"); } function hasPendingActions() external virtual view returns (bool) { - revert("Unsupported"); + revert("hasPendingActions unsupported"); } function processPendingActions(uint) external virtual returns (bool) { - revert("Unsupported"); + revert("processPendingActions unsupported"); } function contractStake(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("contractStake unsupported"); } function stakerReward(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("stakerReward unsupported"); } function stakerDeposit(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("stakerDeposit unsupported"); } function stakerContractStake(address, address) external virtual view returns (uint) { - revert("Unsupported"); + revert("stakerContractStake unsupported"); } function withdraw(uint) external virtual { - revert("Unsupported"); + revert("withdraw unsupported"); } function withdrawForUser(address) external virtual { - revert("Unsupported"); + revert("withdrawForUser unsupported"); } function stakerMaxWithdrawable(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("stakerMaxWithdrawable unsupported"); } function withdrawReward(address) external virtual { - revert("Unsupported"); + revert("withdrawReward unsupported"); } } diff --git a/contracts/mocks/generic/StakingPoolGeneric.sol b/contracts/mocks/generic/StakingPoolGeneric.sol index e0d80d76b7..7809420d8a 100644 --- a/contracts/mocks/generic/StakingPoolGeneric.sol +++ b/contracts/mocks/generic/StakingPoolGeneric.sol @@ -6,7 +6,7 @@ import "../../interfaces/IStakingPool.sol"; contract StakingPoolGeneric is IStakingPool { - function initialize(bool, uint, uint, uint, string memory) external virtual { + function initialize(bool, uint, uint, uint) external virtual { revert("Unsupported"); } @@ -117,4 +117,9 @@ contract StakingPoolGeneric is IStakingPool { function getTrancheCapacities(uint, uint, uint, uint, uint) external virtual pure returns (uint[] memory) { revert("Unsupported"); } + + // TODO: remove me after upgrade + function updateRewardsShares(uint, uint[] calldata) external virtual { + revert("Unsupported"); + } } diff --git a/contracts/mocks/generic/StakingProductsGeneric.sol b/contracts/mocks/generic/StakingProductsGeneric.sol index 04f792fbbc..e3bc7a6a60 100644 --- a/contracts/mocks/generic/StakingProductsGeneric.sol +++ b/contracts/mocks/generic/StakingProductsGeneric.sol @@ -28,6 +28,10 @@ contract StakingProductsGeneric is IStakingProducts { revert("Unsupported"); } + function getPoolManager(uint) public pure override returns (address) { + revert("Unsupported"); + } + function getPremium(uint, uint, uint, uint, uint, uint, uint, bool, uint, uint) public virtual returns (uint) { revert("Unsupported"); } @@ -36,7 +40,6 @@ contract StakingProductsGeneric is IStakingProducts { revert("Unsupported"); } - function calculatePremium(StakedProduct memory, uint, uint, uint, uint, uint, uint, uint, uint, uint) public virtual pure returns (uint, StakedProduct memory) { revert("Unsupported"); } @@ -65,4 +68,16 @@ contract StakingProductsGeneric is IStakingProducts { function changeStakingPoolFactoryOperator(address) external virtual pure { revert("Unsupported"); } + + function setPoolMetadata(uint, string memory) external pure { + revert("Unsupported"); + } + + function getPoolMetadata(uint) external pure returns (string memory) { + revert("Unsupported"); + } + + function setInitialMetadata(string[] calldata) external pure { + revert("Unsupported"); + } } diff --git a/contracts/mocks/generic/SwapOperatorGeneric.sol b/contracts/mocks/generic/SwapOperatorGeneric.sol index 1836f798f2..ee8cbba3fe 100644 --- a/contracts/mocks/generic/SwapOperatorGeneric.sol +++ b/contracts/mocks/generic/SwapOperatorGeneric.sol @@ -19,6 +19,48 @@ contract SwapOperatorGeneric is ISwapOperator { revert("Unsupported"); } + function currentOrderUID() external pure returns (bytes memory) { + revert("Unsupported"); + } + + /* ==== IMMUTABLES ==== */ + + function cowSettlement() external pure returns (ICowSettlement) { + revert("Unsupported"); + } + + function cowVaultRelayer() external pure returns (address) { + revert("Unsupported"); + } + + function master() external pure returns (INXMMaster) { + revert("Unsupported"); + } + + function swapController() external pure returns (address) { + revert("Unsupported"); + } + + function weth() external pure returns (IWeth) { + revert("Unsupported"); + } + + function domainSeparator() external pure returns (bytes32) { + revert("Unsupported"); + } + + function enzymeV4VaultProxyAddress() external pure returns (address) { + revert("Unsupported"); + } + + function enzymeFundValueCalculatorRouter() external pure returns (IEnzymeFundValueCalculatorRouter) { + revert("Unsupported"); + } + + function minPoolEth() external pure returns (uint) { + revert("Unsupported"); + } + /* ==== MUTATIVE FUNCTIONS ==== */ function placeOrder(GPv2Order.Data calldata, bytes calldata) external virtual { diff --git a/contracts/mocks/generic/TokenControllerGeneric.sol b/contracts/mocks/generic/TokenControllerGeneric.sol index 878b0f8b41..62601366bd 100644 --- a/contracts/mocks/generic/TokenControllerGeneric.sol +++ b/contracts/mocks/generic/TokenControllerGeneric.sol @@ -15,123 +15,123 @@ contract TokenControllerGeneric is ITokenController { uint[] calldata, uint[] calldata ) external pure { - revert("Unsupported"); + revert("withdrawCoverNote unsupported"); } function changeOperator(address) external pure { - revert("Unsupported"); + revert("changeOperator unsupported"); } function operatorTransfer(address, address, uint) external virtual returns (bool) { - revert("Unsupported"); + revert("operatorTransfer unsupported"); } function burnFrom(address, uint) external virtual returns (bool) { - revert("Unsupported"); + revert("burnFrom unsupported"); } function addToWhitelist(address) external virtual { - revert("Unsupported"); + revert("addToWhitelist unsupported"); } function removeFromWhitelist(address) external virtual { - revert("Unsupported"); + revert("removeFromWhitelist unsupported"); } function mint(address, uint) external virtual { - revert("Unsupported"); + revert("mint unsupported"); } function lockForMemberVote(address, uint) external pure { - revert("Unsupported"); + revert("lockForMemberVote unsupported"); } function withdrawClaimAssessmentTokens(address[] calldata) external pure { - revert("Unsupported"); + revert("withdrawClaimAssessmentTokens unsupported"); } function getLockReasons(address) external pure returns (bytes32[] memory) { - revert("Unsupported"); + revert("getLockReasons unsupported"); } function totalSupply() external virtual view returns (uint) { - revert("Unsupported"); + revert("totalSupply unsupported"); } function totalBalanceOf(address) external pure returns (uint) { - revert("Unsupported"); + revert("totalBalanceOf unsupported"); } function totalBalanceOfWithoutDelegations(address) external pure returns (uint) { - revert("Unsupported"); + revert("totalBalanceOfWithoutDelegations unsupported"); } function getTokenPrice() external pure returns (uint) { - revert("Unsupported"); + revert("getTokenPrice unsupported"); } function getStakingPoolManager(uint) external virtual view returns (address) { - revert("Unsupported"); + revert("getStakingPoolManager unsupported"); } function getManagerStakingPools(address) external pure returns (uint[] memory) { - revert("Unsupported"); + revert("getManagerStakingPools unsupported"); } function isStakingPoolManager(address) external virtual view returns (bool) { - revert("Unsupported"); + revert("isStakingPoolManager unsupported"); } function getStakingPoolOwnershipOffer(uint) external pure returns (address, uint) { - revert("Unsupported"); + revert("getStakingPoolOwnershipOffer unsupported"); } function transferStakingPoolsOwnership(address, address) external virtual { - revert("Unsupported"); + revert("transferStakingPoolsOwnership unsupported"); } function assignStakingPoolManager(uint, address) external virtual { - revert("Unsupported"); + revert("assignStakingPoolManager unsupported"); } function createStakingPoolOwnershipOffer(uint, address, uint) external pure { - revert("Unsupported"); + revert("createStakingPoolOwnershipOffer unsupported"); } function acceptStakingPoolOwnershipOffer(uint) external pure { - revert("Unsupported"); + revert("acceptStakingPoolOwnershipOffer unsupported"); } function cancelStakingPoolOwnershipOffer(uint) external pure { - revert("Unsupported"); + revert("cancelStakingPoolOwnershipOffer unsupported"); } function mintStakingPoolNXMRewards(uint, uint) external virtual { - revert("Unsupported"); + revert("mintStakingPoolNXMRewards unsupported"); } function burnStakingPoolNXMRewards(uint, uint) external virtual { - revert("Unsupported"); + revert("burnStakingPoolNXMRewards unsupported"); } function depositStakedNXM(address, uint, uint) external virtual { - revert("Unsupported"); + revert("depositStakedNXM unsupported"); } function withdrawNXMStakeAndRewards(address, uint, uint, uint) external virtual { - revert("Unsupported"); + revert("withdrawNXMStakeAndRewards unsupported"); } function burnStakedNXM(uint, uint) external virtual { - revert("Unsupported"); + revert("burnStakedNXM unsupported"); } function stakingPoolNXMBalances(uint) external virtual view returns(uint128, uint128) { - revert("Unsupported"); + revert("stakingPoolNXMBalances unsupported"); } function tokensLocked(address, bytes32) external virtual view returns (uint256) { - revert("Unsupported"); + revert("tokensLocked unsupported"); } function getWithdrawableCoverNotes(address) external virtual view returns ( @@ -139,10 +139,19 @@ contract TokenControllerGeneric is ITokenController { bytes32[] memory, uint ) { - revert("Unsupported"); + revert("getWithdrawableCoverNotes unsupported"); } function getPendingRewards(address) external virtual view returns (uint) { - revert("Unsupported"); + revert("getPendingRewards unsupported"); + } + + function withdrawNXM( + StakingPoolDeposit[] calldata, + StakingPoolManagerReward[] calldata, + uint, + WithdrawAssessment calldata + ) external virtual { + revert("withdrawNXM unsupported"); } } diff --git a/contracts/mocks/modules/AssessmentViewer/AVMockAssessment.sol b/contracts/mocks/modules/AssessmentViewer/AVMockAssessment.sol index f6ea25fefd..48939de95f 100644 --- a/contracts/mocks/modules/AssessmentViewer/AVMockAssessment.sol +++ b/contracts/mocks/modules/AssessmentViewer/AVMockAssessment.sol @@ -2,13 +2,10 @@ pragma solidity >=0.5.0; -import {IAssessment} from "../../../interfaces/IAssessment.sol"; +import {AssessmentGeneric} from "../../generic/AssessmentGeneric.sol"; -contract AVMockAssessment is IAssessment { +contract AVMockAssessment is AssessmentGeneric { - Configuration public override config; - mapping(address => Stake) public override stakeOf; - mapping(address => Vote[]) public override votesOf; uint totalPendingAmountInNXM; uint withdrawableAmountInNXM; uint withdrawableUntilIndex; @@ -65,60 +62,4 @@ contract AVMockAssessment is IAssessment { function getRewards(address) external view override returns (uint, uint, uint) { return (totalPendingAmountInNXM, withdrawableAmountInNXM, withdrawableUntilIndex); } - - /* ========== NOT YET IMPLEMENTED ========== */ - - function getAssessmentsCount() external pure override returns (uint) { - revert("getAssessmentsCount not yet implemented"); - } - - function assessments(uint) external pure override returns (Poll memory, uint128, uint128) { - revert("assessments not yet implemented"); - } - - function getPoll(uint) external pure override returns (Poll memory) { - revert("getPoll not yet implemented"); - } - - function hasAlreadyVotedOn(address, uint) external pure override returns (bool) { - revert("hasAlreadyVotedOn not yet implemented"); - } - - /* === MUTATIVE FUNCTIONS ==== */ - - function stake(uint96) external pure override { - revert("stake not yet implemented"); - } - - function unstake(uint96, address) external pure override { - revert("unstake not yet implemented"); - } - - function withdrawRewards(address, uint104) external pure override returns (uint, uint) { - revert("withdrawRewards not yet implemented"); - } - - function withdrawRewardsTo(address, uint104) external pure override returns (uint, uint) { - revert("withdrawRewardsTo not yet implemented"); - } - - function startAssessment(uint, uint) external pure override returns (uint) { - revert("startAssessment not yet implemented"); - } - - function castVotes(uint[] calldata, bool[] calldata, string[] calldata, uint96) external pure override { - revert("castVotes not yet implemented"); - } - - function submitFraud(bytes32) external pure override { - revert("submitFraud not yet implemented"); - } - - function processFraud(uint256, bytes32[] calldata, address, uint256, uint96, uint16, uint256) external pure override { - revert("processFraud not yet implemented"); - } - - function updateUintParameters(UintParams[] calldata, uint[] calldata) external pure override { - revert("updateUintParameters not yet implemented"); - } } diff --git a/contracts/mocks/modules/Cover/COMockStakingPool.sol b/contracts/mocks/modules/Cover/COMockStakingPool.sol index be465857c8..0b8246d4a6 100644 --- a/contracts/mocks/modules/Cover/COMockStakingPool.sol +++ b/contracts/mocks/modules/Cover/COMockStakingPool.sol @@ -54,14 +54,12 @@ contract COMockStakingPool is StakingPoolGeneric { bool _isPrivatePool, uint _initialPoolFee, uint _maxPoolFee, - uint _poolId, - string calldata _ipfsDescriptionHash /* ipfsDescriptionHash */ + uint _poolId ) external override { isPrivatePool = _isPrivatePool; poolFee = uint8(_initialPoolFee); maxPoolFee = uint8(_maxPoolFee); poolId = uint40(_poolId); - ipfsHash = _ipfsDescriptionHash; } function requestAllocation( diff --git a/contracts/mocks/modules/Cover/COMockStakingProducts.sol b/contracts/mocks/modules/Cover/COMockStakingProducts.sol index 313f646407..6b88638a07 100644 --- a/contracts/mocks/modules/Cover/COMockStakingProducts.sol +++ b/contracts/mocks/modules/Cover/COMockStakingProducts.sol @@ -70,7 +70,7 @@ contract COMockStakingProducts is StakingProductsGeneric { uint initialPoolFee, uint maxPoolFee, ProductInitializationParams[] memory productInitParams, - string calldata ipfsDescriptionHash + string calldata /*ipfsDescriptionHash*/ ) external override returns (uint /*poolId*/, address /*stakingPoolAddress*/) { uint numProducts = productInitParams.length; @@ -110,8 +110,7 @@ contract COMockStakingProducts is StakingProductsGeneric { isPrivatePool, initialPoolFee, maxPoolFee, - poolId, - ipfsDescriptionHash + poolId ); tokenController().assignStakingPoolManager(poolId, msg.sender); diff --git a/contracts/mocks/modules/TokenController/TCMockGovernance.sol b/contracts/mocks/modules/TokenController/TCMockGovernance.sol index 3402e32c49..fb2b7405e2 100644 --- a/contracts/mocks/modules/TokenController/TCMockGovernance.sol +++ b/contracts/mocks/modules/TokenController/TCMockGovernance.sol @@ -18,6 +18,10 @@ contract TCMockGovernance { unclaimedGovernanceRewards[_memberAddress] = 0; } + function getPendingReward(address _memberAddress) external view returns(uint) { + return unclaimedGovernanceRewards[_memberAddress]; + } + function setUnclaimedGovernanceRewards(address _memberAddress, uint amount) public { unclaimedGovernanceRewards[_memberAddress] = amount; } diff --git a/contracts/mocks/modules/TokenController/TCMockPooledStaking.sol b/contracts/mocks/modules/TokenController/TCMockPooledStaking.sol index a09d1d7a67..42b3baa382 100644 --- a/contracts/mocks/modules/TokenController/TCMockPooledStaking.sol +++ b/contracts/mocks/modules/TokenController/TCMockPooledStaking.sol @@ -3,13 +3,21 @@ pragma solidity ^0.8.18; import "../../../abstract/MasterAwareV2.sol"; import "../../../interfaces/IPooledStaking.sol"; +import "../../../interfaces/INXMToken.sol"; import "../../generic/PooledStakingGeneric.sol"; -import "hardhat/console.sol"; contract TCMockPooledStaking is PooledStakingGeneric { mapping(address => uint) public _stakerReward; mapping(address => uint) public _stakerDeposit; + INXMToken internal immutable token; + + event Withdrawn(address indexed staker, uint amount); + event RewardWithdrawn(address indexed staker, uint amount); + + constructor(address _tokenAddress) { + token = INXMToken(_tokenAddress); + } // Manually set the staker reward function setStakerReward(address staker, uint reward) external { @@ -28,4 +36,18 @@ contract TCMockPooledStaking is PooledStakingGeneric { function stakerDeposit(address staker) external override view returns (uint) { return _stakerDeposit[staker]; } + + function withdrawForUser(address member) external override { + uint amount = _stakerDeposit[member]; + _stakerDeposit[member] = 0; + token.mint(member, amount); + emit Withdrawn(member, amount); + } + + function withdrawReward(address member) external override { + uint amount = _stakerReward[member]; + _stakerReward[member] = 0; + token.mint(member, amount); + emit RewardWithdrawn(member, amount); + } } diff --git a/contracts/mocks/modules/TokenController/TCMockStakingNFT.sol b/contracts/mocks/modules/TokenController/TCMockStakingNFT.sol new file mode 100644 index 0000000000..5257fa0de2 --- /dev/null +++ b/contracts/mocks/modules/TokenController/TCMockStakingNFT.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.18; + +import "../../tokens/ERC721Mock.sol"; + +contract TCMockStakingNFT is ERC721Mock { + + constructor() ERC721Mock("", "") {} + + mapping(uint => uint) public _stakingPoolOf; + + function mint(uint poolId, address to) external returns (uint) { + uint tokenId = ++totalSupply; + _mint(to, tokenId); + _stakingPoolOf[tokenId] = poolId; + return tokenId; + } + + function stakingPoolOf(uint tokenId) external view returns (uint) { + // ownerOf will revert for non-existing tokens which is what we want here + ownerOf(tokenId); + return _stakingPoolOf[tokenId]; + } + +} diff --git a/contracts/modules/assessment/Assessment.sol b/contracts/modules/assessment/Assessment.sol index 83227d6d73..067d742b34 100644 --- a/contracts/modules/assessment/Assessment.sol +++ b/contracts/modules/assessment/Assessment.sol @@ -41,6 +41,15 @@ contract Assessment is IAssessment, MasterAwareV2 { Assessment[] public override assessments; + /* ========== MODIFIERS ========== */ + + modifier onlyTokenController { + if (msg.sender != getInternalContractAddress(ID.TC)) { + revert OnlyTokenController(); + } + _; + } + /* ========== CONSTRUCTOR ========== */ constructor(address nxmAddress) { @@ -78,6 +87,7 @@ contract Assessment is IAssessment, MasterAwareV2 { uint withdrawableAmountInNXM, uint withdrawableUntilIndex ) { + uint104 rewardsWithdrawableFromIndex = stakeOf[staker].rewardsWithdrawableFromIndex; Vote memory vote; Assessment memory assessment; @@ -117,9 +127,9 @@ contract Assessment is IAssessment, MasterAwareV2 { /// /// @param amount The amount of nxm to stake function stake(uint96 amount) public whenNotPaused { + stakeOf[msg.sender].amount += amount; - ITokenController(getInternalContractAddress(ID.TC)) - .operatorTransfer(msg.sender, address(this), amount); + _tokenController().operatorTransfer(msg.sender, address(this), amount); emit StakeDeposited(msg.sender, amount); } @@ -132,25 +142,45 @@ contract Assessment is IAssessment, MasterAwareV2 { /// @param to The member address where the NXM is transfered to. Useful for switching /// membership during stake lockup period and thus allowing the user to withdraw /// their staked amount to the new address when possible. - function unstake(uint96 amount, address to) external whenNotPaused override { - // Restrict unstaking to a different account if still locked for member vote - if (block.timestamp < nxm.isLockedForMV(msg.sender)) { - require(to == msg.sender, "Assessment: NXM is locked for voting in governance"); + function unstake(uint96 amount, address to) external override whenNotPaused { + + uint stakeAmount = stakeOf[msg.sender].amount; + if (amount > stakeAmount) { + revert InvalidAmount(stakeAmount); } - uint voteCount = votesOf[msg.sender].length; + uint govLockupExpiry = nxm.isLockedForMV(msg.sender); + if (block.timestamp < govLockupExpiry && to != msg.sender) { + revert StakeLockedForGovernance(govLockupExpiry); + } + + _unstake(msg.sender, amount, to); + } + + /// Withdraws all of the the given staker's stake + /// + /// @dev At least stakeLockupPeriodInDays must have passed since the last vote. + /// + /// @param staker The address of the staker whose stake will be unstaked + function unstakeAllFor(address staker) external override whenNotPaused onlyTokenController { + _unstake(staker, stakeOf[staker].amount, staker); + } + + function _unstake(address staker, uint96 amount, address to) internal { + + uint voteCount = votesOf[staker].length; if (voteCount > 0) { - Vote memory vote = votesOf[msg.sender][voteCount - 1]; - require( - block.timestamp > vote.timestamp + uint(config.stakeLockupPeriodInDays) * 1 days, - "Stake is in lockup period" - ); + Vote memory latestVote = votesOf[staker][voteCount - 1]; + uint assessmentLockupExpiry = latestVote.timestamp + uint(config.stakeLockupPeriodInDays) * 1 days; + if (block.timestamp <= assessmentLockupExpiry) { + revert StakeLockedForAssessment(assessmentLockupExpiry); + } } - stakeOf[msg.sender].amount -= amount; + stakeOf[staker].amount -= amount; nxm.transfer(to, amount); - emit StakeWithdrawn(msg.sender, to, amount); + emit StakeWithdrawn(staker, to, amount); } /// Withdraws a staker's accumulated rewards to a destination address but only the staker can @@ -162,6 +192,8 @@ contract Assessment is IAssessment, MasterAwareV2 { /// @param batchSize The index until which (but not including) the rewards should be withdrawn. /// Used if a large number of assessments accumulates and the function doesn't /// fit in one block, thus requiring multiple batched transactions. + /// @return withdrawn The amount of rewards withdrawn. + /// @return withdrawnUntilIndex The index up to (but not including) the withdrawal was processed. function withdrawRewards( address staker, uint104 batchSize @@ -177,6 +209,8 @@ contract Assessment is IAssessment, MasterAwareV2 { /// @param batchSize The index until which (but not including) the rewards should be withdrawn. /// Used if a large number of assessments accumulates and the function doesn't /// fit in one block, thus requiring multiple batched transactions. + /// @return withdrawn The amount of rewards withdrawn. + /// @return withdrawnUntilIndex The index up to (but not including) the withdrawal was processed. function withdrawRewardsTo( address destination, uint104 batchSize @@ -189,20 +223,15 @@ contract Assessment is IAssessment, MasterAwareV2 { address destination, uint104 batchSize ) internal returns (uint withdrawn, uint withdrawnUntilIndex) { - require( - IMemberRoles(internalContracts[uint(ID.MR)]).checkRole( - destination, - uint(IMemberRoles.Role.Member) - ), - "Destination address is not a member" - ); // This is the index until which (but not including) the previous withdrawal was processed. // The current withdrawal starts from this index. uint104 rewardsWithdrawableFromIndex = stakeOf[staker].rewardsWithdrawableFromIndex; { uint voteCount = votesOf[staker].length; - require(rewardsWithdrawableFromIndex < voteCount, "No withdrawable rewards"); + if (rewardsWithdrawableFromIndex >= voteCount) { + revert NoWithdrawableRewards(); + } // If batchSize is a non-zero value, it means the withdrawal is going to be batched in // multiple transactions. withdrawnUntilIndex = batchSize > 0 ? rewardsWithdrawableFromIndex + batchSize : voteCount; @@ -225,12 +254,11 @@ contract Assessment is IAssessment, MasterAwareV2 { // This is the index where the next withdrawReward call will start iterating from stakeOf[staker].rewardsWithdrawableFromIndex = SafeUintCast.toUint104(withdrawnUntilIndex); - ITokenController(getInternalContractAddress(ID.TC)).mint(destination, withdrawn); + _tokenController().mint(destination, withdrawn); emit RewardWithdrawn(staker, destination, withdrawn); } - /// Creates a new assessment /// /// @dev Is used only by contracts acting as redemption methods for cover product types. @@ -244,6 +272,7 @@ contract Assessment is IAssessment, MasterAwareV2 { uint totalAssessmentReward, uint assessmentDepositInETH ) external override onlyInternal returns (uint) { + assessments.push(Assessment( Poll( 0, // accepted @@ -254,6 +283,7 @@ contract Assessment is IAssessment, MasterAwareV2 { uint128(totalAssessmentReward), uint128(assessmentDepositInETH) )); + return assessments.length - 1; } @@ -274,21 +304,20 @@ contract Assessment is IAssessment, MasterAwareV2 { string[] calldata ipfsAssessmentDataHashes, uint96 stakeIncrease ) external override onlyMember whenNotPaused { - require( - assessmentIds.length == votes.length, - "The lengths of the assessment ids and votes arrays mismatch" - ); - require( - assessmentIds.length == ipfsAssessmentDataHashes.length, - "The lengths of the assessment ids and ipfs assessment data hashes arrays mismatch" - ); + + if (assessmentIds.length != votes.length) { + revert AssessmentIdsVotesLengthMismatch(); + } + if (assessmentIds.length != ipfsAssessmentDataHashes.length) { + revert AssessmentIdsIpfsLengthMismatch(); + } if (stakeIncrease > 0) { stake(stakeIncrease); } for (uint i = 0; i < assessmentIds.length; i++) { - castVote(assessmentIds[i], votes[i], ipfsAssessmentDataHashes[i]); + _castVote(assessmentIds[i], votes[i], ipfsAssessmentDataHashes[i]); } } @@ -303,21 +332,27 @@ contract Assessment is IAssessment, MasterAwareV2 { /// /// @param assessmentId The index of the assessment for which the vote is cast /// @param isAcceptVote True to accept, false to deny - function castVote(uint assessmentId, bool isAcceptVote, string memory ipfsAssessmentDataHash) internal { + function _castVote(uint assessmentId, bool isAcceptVote, string memory ipfsAssessmentDataHash) internal { + { - require(!hasAlreadyVotedOn[msg.sender][assessmentId], "Already voted"); + if (hasAlreadyVotedOn[msg.sender][assessmentId]) { + revert AlreadyVoted(); + } hasAlreadyVotedOn[msg.sender][assessmentId] = true; } uint96 stakeAmount = stakeOf[msg.sender].amount; - require(stakeAmount > 0, "A stake is required to cast votes"); + if (stakeAmount <= 0) { + revert StakeRequired(); + } Poll memory poll = assessments[assessmentId].poll; - require(block.timestamp < poll.end, "Voting is closed"); - require( - poll.accepted > 0 || isAcceptVote, - "At least one accept vote is required to vote deny" - ); + if (block.timestamp >= poll.end) { + revert VotingClosed(); + } + if (!(poll.accepted > 0 || isAcceptVote)) { + revert AcceptVoteRequired(); + } if (poll.accepted == 0) { // Reset the poll end date on the first accept vote @@ -390,14 +425,16 @@ contract Assessment is IAssessment, MasterAwareV2 { uint16 fraudCount, uint256 voteBatchSize ) external override whenNotPaused { - require( - MerkleProof.verify( + + if ( + !MerkleProof.verify( proof, fraudResolution[rootIndex], keccak256(abi.encodePacked(assessor, lastFraudulentVoteIndex, burnAmount, fraudCount)) - ), - "Invalid merkle proof" - ); + ) + ) { + revert InvalidMerkleProof(); + } Stake memory _stake = stakeOf[assessor]; @@ -448,7 +485,7 @@ contract Assessment is IAssessment, MasterAwareV2 { _stake.fraudCount++; // TODO: consider burning the tokens in the token controller contract - ramm().updateTwap(); + _ramm().updateTwap(); nxm.burn(burnAmount); } @@ -468,20 +505,26 @@ contract Assessment is IAssessment, MasterAwareV2 { UintParams[] calldata paramNames, uint[] calldata values ) external override onlyGovernance { + Configuration memory newConfig = config; + for (uint i = 0; i < paramNames.length; i++) { + if (paramNames[i] == IAssessment.UintParams.minVotingPeriodInDays) { newConfig.minVotingPeriodInDays = uint8(values[i]); continue; } + if (paramNames[i] == IAssessment.UintParams.stakeLockupPeriodInDays) { newConfig.stakeLockupPeriodInDays = uint8(values[i]); continue; } + if (paramNames[i] == IAssessment.UintParams.payoutCooldownInDays) { newConfig.payoutCooldownInDays = uint8(values[i]); continue; } + if (paramNames[i] == IAssessment.UintParams.silentEndingPeriodInDays) { newConfig.silentEndingPeriodInDays = uint8(values[i]); continue; @@ -490,9 +533,20 @@ contract Assessment is IAssessment, MasterAwareV2 { config = newConfig; } + /* ========== DEPENDENCIES ========== */ + + function _tokenController() internal view returns (ITokenController) { + return ITokenController(getInternalContractAddress(ID.TC)); + } + + function _ramm() internal view returns (IRamm) { + return IRamm(getInternalContractAddress(ID.RA)); + } + /// @dev Updates internal contract addresses to the ones stored in master. This function is /// automatically called by the master contract when a contract is added or upgraded. function changeDependentContractAddress() external override { + internalContracts[uint(ID.TC)] = master.getLatestAddress("TC"); internalContracts[uint(ID.MR)] = master.getLatestAddress("MR"); internalContracts[uint(ID.RA)] = master.getLatestAddress("RA"); @@ -514,11 +568,7 @@ contract Assessment is IAssessment, MasterAwareV2 { config.stakeLockupPeriodInDays = 14; // days config.silentEndingPeriodInDays = 1; // days // whitelist current contract - ITokenController(getInternalContractAddress(ID.TC)).addToWhitelist(address(this)); + _tokenController().addToWhitelist(address(this)); } } - - function ramm() internal view returns (IRamm) { - return IRamm(internalContracts[uint(ID.RA)]); - } } diff --git a/contracts/modules/capital/SwapOperator.sol b/contracts/modules/capital/SwapOperator.sol index 6b23e23e83..689bb8a18b 100644 --- a/contracts/modules/capital/SwapOperator.sol +++ b/contracts/modules/capital/SwapOperator.sol @@ -5,8 +5,6 @@ pragma solidity ^0.8.18; import "@openzeppelin/contracts-v4/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts-v4/token/ERC20/utils/SafeERC20.sol"; -import "../../interfaces/ICowSettlement.sol"; -import "../../interfaces/INXMMaster.sol"; import "../../interfaces/IPool.sol"; import "../../interfaces/ISwapOperator.sol"; import "../../interfaces/IPriceFeedOracle.sol"; diff --git a/contracts/modules/cover/Cover.sol b/contracts/modules/cover/Cover.sol index c5ff434a8c..b123306a9f 100644 --- a/contracts/modules/cover/Cover.sol +++ b/contracts/modules/cover/Cover.sol @@ -8,6 +8,7 @@ import "@openzeppelin/contracts-v4/token/ERC20/utils/SafeERC20.sol"; import "../../abstract/MasterAwareV2.sol"; import "../../abstract/Multicall.sol"; +import "../../interfaces/ICompleteStakingPoolFactory.sol"; import "../../interfaces/ICover.sol"; import "../../interfaces/ICoverNFT.sol"; import "../../interfaces/ICoverProducts.sol"; @@ -15,7 +16,7 @@ import "../../interfaces/IPool.sol"; import "../../interfaces/IStakingNFT.sol"; import "../../interfaces/IStakingPool.sol"; import "../../interfaces/IStakingPoolBeacon.sol"; -import "../../interfaces/ICompleteStakingPoolFactory.sol"; +import "../../interfaces/ISwapOperator.sol"; import "../../interfaces/ITokenController.sol"; import "../../libraries/Math.sol"; import "../../libraries/SafeUintCast.sol"; @@ -81,6 +82,8 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu // smallest unit we can allocate is 1e18 / 100 = 1e16 = 0.01 NXM uint public constant NXM_PER_ALLOCATION_UNIT = ONE_NXM / ALLOCATION_UNITS_PER_NXM; + uint private constant MAX_ACTIVE_TRANCHES = 8; // 7 whole quarters + 1 partial quarter + ICoverNFT public immutable override coverNFT; IStakingNFT public immutable override stakingNFT; ICompleteStakingPoolFactory public immutable override stakingPoolFactory; @@ -682,6 +685,31 @@ contract Cover is ICover, MasterAwareV2, IStakingPoolBeacon, ReentrancyGuard, Mu stakingPoolFactory.changeOperator(_operator); } + /* ========== Temporary utilities ========== */ + + function updateStakingPoolsRewardShares( + uint[][][] calldata tokenIds // tokenIds[ pool_id ][ tranche_idx ] => [token ids] + ) external { + + ISwapOperator swapOperator = ISwapOperator(pool().swapOperator()); + + if (msg.sender != swapOperator.swapController()) { + revert OnlySwapOperator(); + } + + uint firstActiveTrancheId = block.timestamp / 91 days; // TRANCHE_DURATION = 91 days + uint stakingPoolCount = stakingPoolFactory.stakingPoolCount(); + + for (uint poolIndex = 0; poolIndex < stakingPoolCount; poolIndex++) { + IStakingPool sp = IStakingPool(StakingPoolLibrary.getAddress(address(stakingPoolFactory), poolIndex + 1)); + sp.processExpirations(true); + + for (uint trancheIdx = 0; trancheIdx < MAX_ACTIVE_TRANCHES; trancheIdx++) { + sp.updateRewardsShares(firstActiveTrancheId + trancheIdx, tokenIds[poolIndex][trancheIdx]); + } + } + } + /* ========== DEPENDENCIES ========== */ function pool() internal view returns (IPool) { diff --git a/contracts/modules/staking/StakingExtrasLib.sol b/contracts/modules/staking/StakingExtrasLib.sol new file mode 100644 index 0000000000..66819cf223 --- /dev/null +++ b/contracts/modules/staking/StakingExtrasLib.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.18; + +import "../../interfaces/IStakingPool.sol"; +import "../../libraries/Math.sol"; +import "../../libraries/UncheckedMath.sol"; +import "../../libraries/SafeUintCast.sol"; + +library StakingExtrasLib { + using SafeUintCast for uint; + using UncheckedMath for uint; + + uint public constant TRANCHE_DURATION = 91 days; + uint public constant MAX_ACTIVE_TRANCHES = 8; + uint public constant POOL_FEE_DENOMINATOR = 100; + uint public constant ONE_NXM = 1 ether; + + function updateRewardsShares( + // storage refs + mapping(uint => mapping(uint => IStakingPool.Deposit)) storage deposits, + mapping(uint => IStakingPool.Tranche) storage tranches, + // state + uint accNxmPerRewardsShare, + uint rewardsSharesSupply, + uint poolFee, + // input + uint trancheId, + uint[] calldata tokenIds + ) external returns (uint newRewardsSharesSupply) { + + IStakingPool.Deposit memory feeDeposit = deposits[0][trancheId]; + IStakingPool.Tranche memory tranche = tranches[trancheId]; + + { + // update manager's pending rewards + uint newRewardPerRewardsShare = accNxmPerRewardsShare.uncheckedSub(feeDeposit.lastAccNxmPerRewardShare); + feeDeposit.pendingRewards += (newRewardPerRewardsShare * feeDeposit.rewardsShares / ONE_NXM).toUint96(); + feeDeposit.lastAccNxmPerRewardShare = accNxmPerRewardsShare.toUint96(); + } + + uint trancheShares; + + for (uint i = 0; i < tokenIds.length; i++) { + require(tokenIds[i] != 0, "INVALID_TOKEN_ID"); + + // sload and sum up + IStakingPool.Deposit memory deposit = deposits[tokenIds[i]][trancheId]; + trancheShares += deposit.stakeShares; + + // update + uint newRewardPerRewardsShare = accNxmPerRewardsShare.uncheckedSub(deposit.lastAccNxmPerRewardShare); + deposit.pendingRewards += (newRewardPerRewardsShare * deposit.rewardsShares / ONE_NXM).toUint96(); + deposit.lastAccNxmPerRewardShare = accNxmPerRewardsShare.toUint96(); + + // reset rewards shares + deposit.rewardsShares = deposit.stakeShares; + + // sstore + deposits[tokenIds[i]][trancheId] = deposit; + } + + // make sure all deposits (token ids) for the current tranche were included in the input + require(trancheShares == tranche.stakeShares, "INVALID_TOTAL_SHARES"); + + // update manager's rewards shares + feeDeposit.rewardsShares = (trancheShares * poolFee / (POOL_FEE_DENOMINATOR - poolFee)).toUint128(); + + { + // update tranche rewards shares and supply + uint previousRewardsShares = tranche.rewardsShares; + uint updatedRewardsShares = trancheShares + feeDeposit.rewardsShares; + + tranche.rewardsShares = updatedRewardsShares.toUint128(); + rewardsSharesSupply = rewardsSharesSupply - previousRewardsShares + updatedRewardsShares; + } + + // sstore + deposits[0][trancheId] = feeDeposit; + tranches[trancheId] = tranche; + + return rewardsSharesSupply; + } +} diff --git a/contracts/modules/staking/StakingPool.sol b/contracts/modules/staking/StakingPool.sol index 2a691de581..25d1758cdf 100644 --- a/contracts/modules/staking/StakingPool.sol +++ b/contracts/modules/staking/StakingPool.sol @@ -3,16 +3,17 @@ pragma solidity ^0.8.18; import "../../abstract/Multicall.sol"; -import "../../interfaces/IStakingPool.sol"; -import "../../interfaces/IStakingNFT.sol"; -import "../../interfaces/ITokenController.sol"; import "../../interfaces/INXMMaster.sol"; import "../../interfaces/INXMToken.sol"; +import "../../interfaces/IStakingNFT.sol"; +import "../../interfaces/IStakingPool.sol"; import "../../interfaces/IStakingProducts.sol"; +import "../../interfaces/ITokenController.sol"; import "../../libraries/Math.sol"; -import "../../libraries/UncheckedMath.sol"; import "../../libraries/SafeUintCast.sol"; +import "../../libraries/UncheckedMath.sol"; import "./StakingTypesLib.sol"; +import "./StakingExtrasLib.sol"; // total stake = active stake + expired stake // total capacity = active stake * global capacity factor @@ -88,12 +89,12 @@ contract StakingPool is IStakingPool, Multicall { /* immutables */ - IStakingNFT public immutable stakingNFT; - INXMToken public immutable nxm; - ITokenController public immutable tokenController; - address public immutable coverContract; - INXMMaster public immutable masterContract; - IStakingProducts public immutable stakingProducts; + IStakingNFT internal immutable stakingNFT; + INXMToken internal immutable nxm; + ITokenController internal immutable tokenController; + address internal immutable coverContract; + INXMMaster internal immutable masterContract; + IStakingProducts internal immutable stakingProducts; /* constants */ @@ -105,8 +106,6 @@ contract StakingPool is IStakingPool, Multicall { uint public constant COVER_TRANCHE_GROUP_SIZE = 5; uint public constant BUCKET_TRANCHE_GROUP_SIZE = 8; - uint public constant REWARD_BONUS_PER_TRANCHE_RATIO = 10_00; // 10.00% - uint public constant REWARD_BONUS_PER_TRANCHE_DENOMINATOR = 100_00; uint public constant WEIGHT_DENOMINATOR = 100; uint public constant REWARDS_DENOMINATOR = 100_00; uint public constant POOL_FEE_DENOMINATOR = 100; @@ -115,8 +114,6 @@ contract StakingPool is IStakingPool, Multicall { uint public constant GLOBAL_CAPACITY_DENOMINATOR = 100_00; uint public constant CAPACITY_REDUCTION_DENOMINATOR = 100_00; - // +2% for every 1%, ie +200% for 100% - // 1 nxm = 1e18 uint internal constant ONE_NXM = 1 ether; @@ -176,8 +173,7 @@ contract StakingPool is IStakingPool, Multicall { bool _isPrivatePool, uint _initialPoolFee, uint _maxPoolFee, - uint _poolId, - string calldata ipfsDescriptionHash + uint _poolId ) external { if (msg.sender != address(stakingProducts)) { @@ -196,8 +192,6 @@ contract StakingPool is IStakingPool, Multicall { poolFee = uint8(_initialPoolFee); maxPoolFee = uint8(_maxPoolFee); poolId = _poolId.toUint40(); - - emit PoolDescriptionSet(ipfsDescriptionHash); } // updateUntilCurrentTimestamp forces rewards update until current timestamp not just until @@ -380,7 +374,6 @@ contract StakingPool is IStakingPool, Multicall { uint _stakeSharesSupply = stakeSharesSupply; uint _rewardsSharesSupply = rewardsSharesSupply; uint _accNxmPerRewardsShare = accNxmPerRewardsShare; - uint totalAmount; // deposit to token id = 0 is not allowed // we treat it as a flag to create a new token @@ -405,7 +398,7 @@ contract StakingPool is IStakingPool, Multicall { ? Math.sqrt(amount) : _stakeSharesSupply * amount / _activeStake; - uint newRewardsShares; + uint newRewardsShares = newStakeShares; // update deposit and pending reward { @@ -414,14 +407,6 @@ contract StakingPool is IStakingPool, Multicall { ? Deposit(0, 0, 0, 0) : deposits[tokenId][trancheId]; - newRewardsShares = calculateNewRewardShares( - deposit.stakeShares, // initialStakeShares - newStakeShares, // newStakeShares - trancheId, // initialTrancheId - trancheId, // newTrancheId, the same as initialTrancheId in this case - block.timestamp - ); - // if we're increasing an existing deposit if (deposit.rewardsShares != 0) { uint newEarningsPerShare = _accNxmPerRewardsShare.uncheckedSub(deposit.lastAccNxmPerRewardShare); @@ -463,13 +448,12 @@ contract StakingPool is IStakingPool, Multicall { tranches[trancheId] = tranche; } - totalAmount += amount; _activeStake += amount; _stakeSharesSupply += newStakeShares; _rewardsSharesSupply += newRewardsShares; // transfer nxm from the staker and update the pool deposit balance - tokenController.depositStakedNXM(msg.sender, totalAmount, poolId); + tokenController.depositStakedNXM(msg.sender, amount, poolId); // update globals activeStake = _activeStake.toUint96(); @@ -479,46 +463,17 @@ contract StakingPool is IStakingPool, Multicall { emit StakeDeposited(msg.sender, amount, trancheId, tokenId); } - function getTimeLeftOfTranche(uint trancheId, uint blockTimestamp) internal pure returns (uint) { - uint endDate = (trancheId + 1) * TRANCHE_DURATION; - return endDate > blockTimestamp ? endDate - blockTimestamp : 0; - } - - /// Calculates the amount of new reward shares based on the initial and new stake shares - /// - /// @param initialStakeShares Amount of stake shares the deposit is already entitled to - /// @param stakeSharesIncrease Amount of additional stake shares the deposit will be entitled to - /// @param initialTrancheId The id of the initial tranche that defines the deposit period - /// @param newTrancheId The new id of the tranche that will define the deposit period - /// @param blockTimestamp The timestamp of the block when the new shares are recalculated - function calculateNewRewardShares( - uint initialStakeShares, - uint stakeSharesIncrease, - uint initialTrancheId, - uint newTrancheId, - uint blockTimestamp - ) public pure returns (uint) { - - uint timeLeftOfInitialTranche = getTimeLeftOfTranche(initialTrancheId, blockTimestamp); - uint timeLeftOfNewTranche = getTimeLeftOfTranche(newTrancheId, blockTimestamp); - - // the bonus is based on the the time left and the total amount of stake shares (initial + new) - uint newBonusShares = (initialStakeShares + stakeSharesIncrease) - * REWARD_BONUS_PER_TRANCHE_RATIO - * timeLeftOfNewTranche - / TRANCHE_DURATION - / REWARD_BONUS_PER_TRANCHE_DENOMINATOR; - - // for existing deposits, the previous bonus is deducted from the final amount - uint previousBonusSharesDeduction = initialStakeShares - * REWARD_BONUS_PER_TRANCHE_RATIO - * timeLeftOfInitialTranche - / TRANCHE_DURATION - / REWARD_BONUS_PER_TRANCHE_DENOMINATOR; - - return stakeSharesIncrease + newBonusShares - previousBonusSharesDeduction; - } - + /// @notice Withdraws stake and rewards for a given token and specified tranches. + /// @dev The function processes the withdrawal of both stake and rewards for a given staking NFT (`tokenId`). + /// Call StakingPoolViewer.getTokens to retrieve the relevant tranche IDs for a tokenId. + /// A stake can only be withdrawn if the the associated tranche where it was deposited has expired + /// Operates only when the contract is not paused. + /// @param tokenId The ID of the staking NFT representing the deposited stake and its associated rewards. + /// @param withdrawStake Whether to withdraw the total stake associated with the `tokenId`. + /// @param withdrawRewards Whether to withdraw the total rewards associated with the `tokenId`. + /// @param trancheIds An array of tranche IDs associated with the `tokenId`, used to specify which tranches to withdraw from. + /// @return withdrawnStake The total stake withdrawn across all specified tranche IDs for the given `tokenId`. + /// @return withdrawnRewards The total rewards withdrawn across all specified tranche IDs for the given `tokenId`. function withdraw( uint tokenId, bool withdrawStake, @@ -526,79 +481,78 @@ contract StakingPool is IStakingPool, Multicall { uint[] memory trancheIds ) public whenNotPaused returns (uint withdrawnStake, uint withdrawnRewards) { - uint managerLockedInGovernanceUntil = nxm.isLockedForMV(manager()); - // pass false as it does not modify the share supply nor the reward per second processExpirations(true); - uint _accNxmPerRewardsShare = accNxmPerRewardsShare; - uint _firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; + WithdrawTrancheContext memory trancheContext; + trancheContext.withdrawStake = withdrawStake; + trancheContext.withdrawRewards = withdrawRewards; + trancheContext._accNxmPerRewardsShare = accNxmPerRewardsShare; + trancheContext._firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; + trancheContext.managerLockedInGovernanceUntil = nxm.isLockedForMV(manager()); + trancheContext.destination = tokenId == 0 ? manager() : stakingNFT.ownerOf(tokenId); + uint trancheCount = trancheIds.length; for (uint j = 0; j < trancheCount; j++) { uint trancheId = trancheIds[j]; + (uint trancheStakeToWithdraw, uint trancheRewardsToWithdraw) = _processTrancheWithdrawal( + tokenId, + trancheId, + trancheContext + ); - Deposit memory deposit = deposits[tokenId][trancheId]; - - { - uint trancheRewardsToWithdraw; - uint trancheStakeToWithdraw; - - // can withdraw stake only if the tranche is expired - if (withdrawStake && trancheId < _firstActiveTrancheId) { + withdrawnStake += trancheStakeToWithdraw; + withdrawnRewards += trancheRewardsToWithdraw; - // Deposit withdrawals are not permitted while the manager is locked in governance to - // prevent double voting. - if (managerLockedInGovernanceUntil > block.timestamp) { - revert ManagerNxmIsLockedForGovernanceVote(); - } + emit Withdraw(trancheContext.destination, tokenId, trancheId, trancheStakeToWithdraw, trancheRewardsToWithdraw); + } - // calculate the amount of nxm for this deposit - uint stake = expiredTranches[trancheId].stakeAmountAtExpiry; - uint _stakeSharesSupply = expiredTranches[trancheId].stakeSharesSupplyAtExpiry; - trancheStakeToWithdraw = stake * deposit.stakeShares / _stakeSharesSupply; - withdrawnStake += trancheStakeToWithdraw; + tokenController.withdrawNXMStakeAndRewards( + trancheContext.destination, + withdrawnStake, + withdrawnRewards, + poolId + ); - // mark as withdrawn - deposit.stakeShares = 0; - } + return (withdrawnStake, withdrawnRewards); + } + + function _processTrancheWithdrawal( + uint tokenId, + uint trancheId, + WithdrawTrancheContext memory context + ) internal returns (uint trancheStakeToWithdraw, uint trancheRewardsToWithdraw) { - if (withdrawRewards) { + Deposit memory deposit = deposits[tokenId][trancheId]; - // if the tranche is expired, use the accumulator value saved at expiration time - uint accNxmPerRewardShareToUse = trancheId < _firstActiveTrancheId - ? expiredTranches[trancheId].accNxmPerRewardShareAtExpiry - : _accNxmPerRewardsShare; + if (context.withdrawStake && trancheId < context._firstActiveTrancheId) { + if (context.managerLockedInGovernanceUntil > block.timestamp) { + revert ManagerNxmIsLockedForGovernanceVote(); + } - // calculate reward since checkpoint - uint newRewardPerShare = accNxmPerRewardShareToUse.uncheckedSub(deposit.lastAccNxmPerRewardShare); - trancheRewardsToWithdraw = newRewardPerShare * deposit.rewardsShares / ONE_NXM + deposit.pendingRewards; - withdrawnRewards += trancheRewardsToWithdraw; + uint stake = expiredTranches[trancheId].stakeAmountAtExpiry; + uint _stakeSharesSupply = expiredTranches[trancheId].stakeSharesSupplyAtExpiry; + trancheStakeToWithdraw = stake * deposit.stakeShares / _stakeSharesSupply; + deposit.stakeShares = 0; + } - // save checkpoint - deposit.lastAccNxmPerRewardShare = accNxmPerRewardShareToUse.toUint96(); - deposit.pendingRewards = 0; - } + if (context.withdrawRewards) { + uint accNxmPerRewardShareToUse = trancheId < context._firstActiveTrancheId + ? expiredTranches[trancheId].accNxmPerRewardShareAtExpiry + : context._accNxmPerRewardsShare; - emit Withdraw(msg.sender, tokenId, trancheId, trancheStakeToWithdraw, trancheRewardsToWithdraw); - } + uint newRewardPerShare = accNxmPerRewardShareToUse.uncheckedSub(deposit.lastAccNxmPerRewardShare); + trancheRewardsToWithdraw = newRewardPerShare * deposit.rewardsShares / ONE_NXM + deposit.pendingRewards; - deposits[tokenId][trancheId] = deposit; + deposit.lastAccNxmPerRewardShare = accNxmPerRewardShareToUse.toUint96(); + deposit.pendingRewards = 0; } - address destination = tokenId == 0 - ? manager() - : stakingNFT.ownerOf(tokenId); - - tokenController.withdrawNXMStakeAndRewards( - destination, - withdrawnStake, - withdrawnRewards, - poolId - ); + deposits[tokenId][trancheId] = deposit; - return (withdrawnStake, withdrawnRewards); + return (trancheStakeToWithdraw, trancheRewardsToWithdraw); } function requestAllocation( @@ -1095,12 +1049,12 @@ contract StakingPool is IStakingPool, Multicall { /// /// @param tokenId The id of the NFT that proves the ownership of the deposit. /// @param initialTrancheId The id of the tranche the deposit is already a part of. - /// @param newTrancheId The id of the new tranche determining the new deposit period. + /// @param targetTrancheId The id of the target tranche determining the new deposit period. /// @param topUpAmount An optional amount if the user wants to also increase the deposit function extendDeposit( uint tokenId, uint initialTrancheId, - uint newTrancheId, + uint targetTrancheId, uint topUpAmount ) external whenNotPaused whenNotHalted { @@ -1127,42 +1081,41 @@ contract StakingPool is IStakingPool, Multicall { revert NxmIsLockedForGovernanceVote(); } - uint _firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; + if (initialTrancheId >= targetTrancheId) { + revert NewTrancheEndsBeforeInitialTranche(); + } { - if (initialTrancheId >= newTrancheId) { - revert NewTrancheEndsBeforeInitialTranche(); - } - + uint _firstActiveTrancheId = block.timestamp / TRANCHE_DURATION; uint maxTrancheId = _firstActiveTrancheId + MAX_ACTIVE_TRANCHES - 1; - if (newTrancheId > maxTrancheId) { + if (targetTrancheId > maxTrancheId) { revert RequestedTrancheIsNotYetActive(); } - if (newTrancheId < firstActiveTrancheId) { + if (targetTrancheId < firstActiveTrancheId) { revert RequestedTrancheIsExpired(); } - } - // if the initial tranche is expired, withdraw everything and make a new deposit - // this requires the user to have grante sufficient allowance - if (initialTrancheId < _firstActiveTrancheId) { + // if the initial tranche is expired, withdraw everything and make a new deposit + // this requires the user to have grante sufficient allowance + if (initialTrancheId < _firstActiveTrancheId) { - uint[] memory trancheIds = new uint[](1); - trancheIds[0] = initialTrancheId; + uint[] memory trancheIds = new uint[](1); + trancheIds[0] = initialTrancheId; - (uint withdrawnStake, /* uint rewardsToWithdraw */) = withdraw( - tokenId, - true, // withdraw the deposit - true, // withdraw the rewards - trancheIds - ); + (uint withdrawnStake, /* uint rewardsToWithdraw */) = withdraw( + tokenId, + true, // withdraw the deposit + true, // withdraw the rewards + trancheIds + ); - depositTo(withdrawnStake + topUpAmount, newTrancheId, tokenId, msg.sender); + depositTo(withdrawnStake + topUpAmount, targetTrancheId, tokenId, msg.sender); - return; - // done! skip the rest of the function. + return; + // done! skip the rest of the function. + } } // if we got here - the initial tranche is still active. move all the shares to the new tranche @@ -1171,94 +1124,109 @@ contract StakingPool is IStakingPool, Multicall { processExpirations(true); Deposit memory initialDeposit = deposits[tokenId][initialTrancheId]; - Deposit memory updatedDeposit = deposits[tokenId][newTrancheId]; + Deposit memory targetDeposit = deposits[tokenId][targetTrancheId]; - uint _activeStake = activeStake; uint _stakeSharesSupply = stakeSharesSupply; - uint newStakeShares; + uint _rewardsSharesSupply = rewardsSharesSupply; + uint _accNxmPerRewardsShare = accNxmPerRewardsShare; - // calculate the new stake shares if there's a deposit top up + // new stake and rewards shares (excluding manager's fee reward shares) + uint newShares; + + // calculate the amount of new shares and update the active stake if (topUpAmount > 0) { - newStakeShares = _stakeSharesSupply * topUpAmount / _activeStake; + uint _activeStake = activeStake; + newShares = _stakeSharesSupply * topUpAmount / _activeStake; activeStake = (_activeStake + topUpAmount).toUint96(); } - // calculate the new reward shares - uint newRewardsShares = calculateNewRewardShares( - initialDeposit.stakeShares, - newStakeShares, - initialTrancheId, - newTrancheId, - block.timestamp - ); + { + // calculate and move the rewards from the initial deposit + uint earningsPerShare = _accNxmPerRewardsShare.uncheckedSub(initialDeposit.lastAccNxmPerRewardShare); + uint newPendingRewards = (earningsPerShare * initialDeposit.rewardsShares / ONE_NXM).toUint96(); + targetDeposit.pendingRewards += (initialDeposit.pendingRewards + newPendingRewards).toUint96(); + } + + // calculate the rewards on the new deposit if it had stake + if (targetDeposit.rewardsShares != 0) { + uint newEarningsPerShare = _accNxmPerRewardsShare.uncheckedSub(targetDeposit.lastAccNxmPerRewardShare); + targetDeposit.pendingRewards += (newEarningsPerShare * targetDeposit.rewardsShares / ONE_NXM).toUint96(); + } + + // update accumulator and shares + targetDeposit.lastAccNxmPerRewardShare = _accNxmPerRewardsShare.toUint96(); + targetDeposit.stakeShares += (initialDeposit.stakeShares + newShares).toUint128(); + targetDeposit.rewardsShares += (initialDeposit.rewardsShares + newShares).toUint128(); + + uint initialFeeRewardShares = initialDeposit.rewardsShares * poolFee / (POOL_FEE_DENOMINATOR - poolFee); + uint newFeeRewardShares = newShares * poolFee / (POOL_FEE_DENOMINATOR - poolFee); + + // update manager's fee deposits + deposits[0][initialTrancheId].rewardsShares -= initialFeeRewardShares.toUint128(); + deposits[0][targetTrancheId].rewardsShares += (initialFeeRewardShares + newFeeRewardShares).toUint128(); + // update tranches { - Tranche memory initialTranche = tranches[initialTrancheId]; - Tranche memory newTranche = tranches[newTrancheId]; + Tranche memory initialTranche = tranches[initialTrancheId]; // sload - // move the shares to the new tranche + // update initialTranche.stakeShares -= initialDeposit.stakeShares; - initialTranche.rewardsShares -= initialDeposit.rewardsShares; - newTranche.stakeShares += initialDeposit.stakeShares + newStakeShares.toUint128(); - newTranche.rewardsShares += (initialDeposit.rewardsShares + newRewardsShares).toUint128(); + initialTranche.rewardsShares -= (initialDeposit.rewardsShares + initialFeeRewardShares).toUint128(); - // store the updated tranches - tranches[initialTrancheId] = initialTranche; - tranches[newTrancheId] = newTranche; + tranches[initialTrancheId] = initialTranche; // sstore } - uint _accNxmPerRewardsShare = accNxmPerRewardsShare; + { + Tranche memory targetTranche = tranches[targetTrancheId]; // sload - // if there already is a deposit on the new tranche, calculate its pending rewards - if (updatedDeposit.lastAccNxmPerRewardShare != 0) { - uint newEarningsPerShare = _accNxmPerRewardsShare.uncheckedSub(updatedDeposit.lastAccNxmPerRewardShare); - updatedDeposit.pendingRewards += (newEarningsPerShare * updatedDeposit.rewardsShares / ONE_NXM).toUint96(); - } + // update + targetTranche.stakeShares += (initialDeposit.stakeShares + newShares).toUint128(); + targetTranche.rewardsShares += initialDeposit.rewardsShares; + targetTranche.rewardsShares += (initialFeeRewardShares + newFeeRewardShares).toUint128(); - // calculate the rewards for the deposit being extended and move them to the new deposit - { - uint newEarningsPerShare = _accNxmPerRewardsShare.uncheckedSub(initialDeposit.lastAccNxmPerRewardShare); - updatedDeposit.pendingRewards += (newEarningsPerShare * initialDeposit.rewardsShares / ONE_NXM).toUint96(); - updatedDeposit.pendingRewards += initialDeposit.pendingRewards; + tranches[targetTrancheId] = targetTranche; // store } - updatedDeposit.lastAccNxmPerRewardShare = _accNxmPerRewardsShare.toUint96(); - updatedDeposit.stakeShares += (initialDeposit.stakeShares + newStakeShares).toUint128(); - updatedDeposit.rewardsShares += (initialDeposit.rewardsShares + newRewardsShares).toUint128(); - - // everything is moved, delete the initial deposit + // delete the initial deposit and store the new deposit delete deposits[tokenId][initialTrancheId]; - - // store the new deposit. - deposits[tokenId][newTrancheId] = updatedDeposit; + deposits[tokenId][targetTrancheId] = targetDeposit; // update global shares supply - stakeSharesSupply = (_stakeSharesSupply + newStakeShares).toUint128(); - rewardsSharesSupply += newRewardsShares.toUint128(); + stakeSharesSupply = (_stakeSharesSupply + newShares).toUint128(); + rewardsSharesSupply = (_rewardsSharesSupply + newShares + newFeeRewardShares).toUint128(); // transfer nxm from the staker and update the pool deposit balance tokenController.depositStakedNXM(msg.sender, topUpAmount, poolId); - emit DepositExtended(msg.sender, tokenId, initialTrancheId, newTrancheId, topUpAmount); + emit DepositExtended(msg.sender, tokenId, initialTrancheId, targetTrancheId, topUpAmount); } function burnStake(uint amount, BurnStakeParams calldata params) external onlyCoverContract { // passing false because neither the amount of shares nor the reward per second are changed processExpirations(false); - // sload - uint _activeStake = activeStake; + // burn stake + { + // sload + uint _activeStake = activeStake; - // If all stake is burned, leave 1 wei and close pool - if (amount >= _activeStake) { - amount = _activeStake - 1; - isHalted = true; - } + // if all stake is burned, leave 1 wei and close pool + if (amount >= _activeStake) { + amount = _activeStake - 1; + isHalted = true; + } - tokenController.burnStakedNXM(amount, poolId); + tokenController.burnStakedNXM(amount, poolId); - // sstore - activeStake = (_activeStake - amount).toUint96(); + // sstore & log event + activeStake = (_activeStake - amount).toUint96(); + emit StakeBurned(amount); + } + + // do not deallocate if the cover has expired (grace period) + if (params.start + params.period <= block.timestamp) { + return; + } uint initialPackedCoverTrancheAllocation = coverTrancheAllocations[params.allocationId]; uint[] memory activeAllocations = getActiveAllocations(params.productId); @@ -1309,8 +1277,6 @@ contract StakingPool is IStakingPool, Multicall { currentFirstActiveTrancheId, activeAllocations ); - - emit StakeBurned(amount); } /* pool management */ @@ -1320,8 +1286,6 @@ contract StakingPool is IStakingPool, Multicall { if (newFee > maxPoolFee) { revert PoolFeeExceedsMax(); } - uint oldFee = poolFee; - poolFee = uint8(newFee); // passing true because the amount of rewards shares changes processExpirations(true); @@ -1329,27 +1293,34 @@ contract StakingPool is IStakingPool, Multicall { uint fromTrancheId = block.timestamp / TRANCHE_DURATION; uint toTrancheId = fromTrancheId + MAX_ACTIVE_TRANCHES - 1; uint _accNxmPerRewardsShare = accNxmPerRewardsShare; + uint _rewardsSharesSupply = rewardsSharesSupply; for (uint trancheId = fromTrancheId; trancheId <= toTrancheId; trancheId++) { // sload Deposit memory feeDeposit = deposits[0][trancheId]; + Tranche memory tranche = tranches[trancheId]; - if (feeDeposit.rewardsShares == 0) { - continue; - } + tranche.rewardsShares -= feeDeposit.rewardsShares; + _rewardsSharesSupply -= feeDeposit.rewardsShares; - // update pending reward and reward shares + // update pending rewards uint newRewardPerRewardsShare = _accNxmPerRewardsShare.uncheckedSub(feeDeposit.lastAccNxmPerRewardShare); feeDeposit.pendingRewards += (newRewardPerRewardsShare * feeDeposit.rewardsShares / ONE_NXM).toUint96(); feeDeposit.lastAccNxmPerRewardShare = _accNxmPerRewardsShare.toUint96(); - // TODO: would using tranche.rewardsShares give a better precision? - feeDeposit.rewardsShares = (uint(feeDeposit.rewardsShares) * newFee / oldFee).toUint128(); + + feeDeposit.rewardsShares = (tranche.rewardsShares * newFee / (POOL_FEE_DENOMINATOR - newFee)).toUint128(); + tranche.rewardsShares += feeDeposit.rewardsShares; + _rewardsSharesSupply += feeDeposit.rewardsShares; // sstore deposits[0][trancheId] = feeDeposit; + tranches[trancheId] = tranche; } + rewardsSharesSupply = _rewardsSharesSupply.toUint128(); + poolFee = uint8(newFee); + emit PoolFeeChanged(msg.sender, newFee); } @@ -1358,8 +1329,27 @@ contract StakingPool is IStakingPool, Multicall { emit PoolPrivacyChanged(msg.sender, _isPrivatePool); } - function setPoolDescription(string memory ipfsDescriptionHash) external onlyManager { - emit PoolDescriptionSet(ipfsDescriptionHash); + /* fixes */ + + function updateRewardsShares( + uint trancheId, + uint[] calldata tokenIds + ) external { + + if (msg.sender != coverContract) { + revert OnlyCoverContract(); + } + + uint _rewardsSharesSupply = StakingExtrasLib.updateRewardsShares( + // storage refs + deposits, tranches, + // state + accNxmPerRewardsShare, rewardsSharesSupply, poolFee, + // inputs + trancheId, tokenIds + ); + + rewardsSharesSupply = _rewardsSharesSupply.toUint128(); } /* getters */ @@ -1422,12 +1412,11 @@ contract StakingPool is IStakingPool, Multicall { uint stakeShares, uint rewardsShares ) { - Deposit memory deposit = deposits[tokenId][trancheId]; return ( - deposit.lastAccNxmPerRewardShare, - deposit.pendingRewards, - deposit.stakeShares, - deposit.rewardsShares + deposits[tokenId][trancheId].lastAccNxmPerRewardShare, + deposits[tokenId][trancheId].pendingRewards, + deposits[tokenId][trancheId].stakeShares, + deposits[tokenId][trancheId].rewardsShares ); } @@ -1435,10 +1424,9 @@ contract StakingPool is IStakingPool, Multicall { uint stakeShares, uint rewardsShares ) { - Tranche memory tranche = tranches[trancheId]; return ( - tranche.stakeShares, - tranche.rewardsShares + tranches[trancheId].stakeShares, + tranches[trancheId].rewardsShares ); } @@ -1447,11 +1435,10 @@ contract StakingPool is IStakingPool, Multicall { uint stakeAmountAtExpiry, uint stakeSharesSupplyAtExpiry ) { - ExpiredTranche memory expiredTranche = expiredTranches[trancheId]; return ( - expiredTranche.accNxmPerRewardShareAtExpiry, - expiredTranche.stakeAmountAtExpiry, - expiredTranche.stakeSharesSupplyAtExpiry + expiredTranches[trancheId].accNxmPerRewardShareAtExpiry, + expiredTranches[trancheId].stakeAmountAtExpiry, + expiredTranches[trancheId].stakeSharesSupplyAtExpiry ); } diff --git a/contracts/modules/staking/StakingProducts.sol b/contracts/modules/staking/StakingProducts.sol index 0f47f41563..8a9ee5b9ee 100644 --- a/contracts/modules/staking/StakingProducts.sol +++ b/contracts/modules/staking/StakingProducts.sol @@ -15,15 +15,29 @@ import "../../libraries/StakingPoolLibrary.sol"; contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { using SafeUintCast for uint; + // pool id => product id => Product + mapping(uint => mapping(uint => StakedProduct)) private _products; + // pool id => { totalEffectiveWeight, totalTargetWeight } + mapping(uint => Weights) public weights; + + // pool id => metadata + mapping(uint => string) internal poolMetadata; + + address public immutable coverContract; + address public immutable stakingPoolFactory; + uint public constant SURGE_PRICE_RATIO = 2 ether; uint public constant SURGE_THRESHOLD_RATIO = 90_00; // 90.00% uint public constant SURGE_THRESHOLD_DENOMINATOR = 100_00; // 100.00% + // base price bump // +0.2% for each 1% of capacity used, ie +20% for 100% uint public constant PRICE_BUMP_RATIO = 20_00; // 20% + // bumped price smoothing // 0.5% per day uint public constant PRICE_CHANGE_PER_DAY = 200; // 2% + uint public constant INITIAL_PRICE_DENOMINATOR = 100_00; uint public constant TARGET_PRICE_DENOMINATOR = 100_00; uint public constant MAX_TOTAL_WEIGHT = 20_00; // 20x @@ -41,13 +55,12 @@ contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { uint public constant ALLOCATION_UNITS_PER_NXM = 100; uint public constant NXM_PER_ALLOCATION_UNIT = ONE_NXM / ALLOCATION_UNITS_PER_NXM; - // pool id => product id => Product - mapping(uint => mapping(uint => StakedProduct)) private _products; - // pool id => { totalEffectiveWeight, totalTargetWeight } - mapping(uint => Weights) public weights; - - address public immutable coverContract; - address public immutable stakingPoolFactory; + modifier onlyManager(uint poolId) { + if (msg.sender != getPoolManager(poolId)) { + revert OnlyManager(); + } + _; + } constructor(address _coverContract, address _stakingPoolFactory) { coverContract = _coverContract; @@ -83,6 +96,14 @@ contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { ); } + function getPoolManager(uint poolId) public view override returns (address) { + return tokenController().getStakingPoolManager(poolId); + } + + function getPoolMetadata(uint poolId) external view override returns (string memory ipfsHash) { + return poolMetadata[poolId]; + } + function recalculateEffectiveWeights(uint poolId, uint[] calldata productIds) external { IStakingPool _stakingPool = stakingPool(poolId); @@ -165,7 +186,7 @@ contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { IStakingPool _stakingPool = stakingPool(poolId); - if (msg.sender != _stakingPool.manager()) { + if (msg.sender != tokenController().getStakingPoolManager(poolId)) { revert OnlyManager(); } @@ -567,29 +588,30 @@ contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { uint initialPoolFee, uint maxPoolFee, ProductInitializationParams[] memory productInitParams, - string calldata ipfsDescriptionHash - ) external whenNotPaused onlyMember returns (uint /*poolId*/, address /*stakingPoolAddress*/) { + string calldata ipfsHash + ) external override whenNotPaused onlyMember returns (uint /*poolId*/, address /*stakingPoolAddress*/) { + if (bytes(ipfsHash).length == 0) { + revert IpfsHashRequired(); + } ICoverProducts _coverProducts = coverProducts(); - ProductInitializationParams[] memory initializedProducts = _coverProducts.prepareStakingProductsParams( - productInitParams - ); - + // create and initialize staking pool (uint poolId, address stakingPoolAddress) = ICompleteStakingPoolFactory(stakingPoolFactory).create(coverContract); + IStakingPool(stakingPoolAddress).initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); - IStakingPool(stakingPoolAddress).initialize( - isPrivatePool, - initialPoolFee, - maxPoolFee, - poolId, - ipfsDescriptionHash - ); - + // assign pool manager tokenController().assignStakingPoolManager(poolId, msg.sender); + // set products + ProductInitializationParams[] memory initializedProducts = _coverProducts.prepareStakingProductsParams( + productInitParams + ); _setInitialProducts(poolId, initializedProducts); + // set metadata + poolMetadata[poolId] = ipfsHash; + return (poolId, stakingPoolAddress); } @@ -637,11 +659,35 @@ contract StakingProducts is IStakingProducts, MasterAwareV2, Multicall { }); } - // future role transfers + // future operator role transfers function changeStakingPoolFactoryOperator(address _operator) external onlyInternal { ICompleteStakingPoolFactory(stakingPoolFactory).changeOperator(_operator); } + function setPoolMetadata( + uint poolId, + string calldata ipfsHash + ) external override onlyManager(poolId) { + if (bytes(ipfsHash).length == 0) { + revert IpfsHashRequired(); + } + poolMetadata[poolId] = ipfsHash; + } + + // temporary migration function + + function setInitialMetadata(string[] calldata ipfsHashes) external onlyAdvisoryBoard { + + uint poolCount = IStakingPoolFactory(stakingPoolFactory).stakingPoolCount(); + require(ipfsHashes.length == poolCount, "StakingProducts: Metadata length mismatch"); + require(bytes(poolMetadata[1]).length == 0, "StakingProducts: Metadata already set"); + + for (uint i = 0; i < poolCount; i++) { + if (bytes(poolMetadata[i + 1]).length != 0) continue; + poolMetadata[i + 1] = ipfsHashes[i]; + } + } + /* dependencies */ function tokenController() internal view returns (ITokenController) { diff --git a/contracts/modules/staking/StakingViewer.sol b/contracts/modules/staking/StakingViewer.sol index 6416549795..2278e610d4 100644 --- a/contracts/modules/staking/StakingViewer.sol +++ b/contracts/modules/staking/StakingViewer.sol @@ -63,6 +63,7 @@ contract StakingViewer is IStakingViewer, Multicall { pool.manager = _stakingPool.manager(); pool.poolFee = _stakingPool.getPoolFee(); pool.maxPoolFee = _stakingPool.getMaxPoolFee(); + pool.metadataIpfsHash = _stakingProducts().getPoolMetadata(poolId); pool.activeStake = activeStake; pool.currentAPY = activeStake != 0 @@ -90,7 +91,7 @@ contract StakingViewer is IStakingViewer, Multicall { pools = new Pool[](poolCount); for (uint i = 0; i < poolCount; i++) { - pools[i] = getPool(i+1); // poolId starts from 1 + pools[i] = getPool(i + 1); // poolId starts from 1 } return pools; diff --git a/contracts/modules/token/TokenController.sol b/contracts/modules/token/TokenController.sol index 3dbfc48143..5f58c6c71b 100644 --- a/contracts/modules/token/TokenController.sol +++ b/contracts/modules/token/TokenController.sol @@ -8,6 +8,7 @@ import "../../interfaces/INXMToken.sol"; import "../../interfaces/IPool.sol"; import "../../interfaces/IPooledStaking.sol"; import "../../interfaces/IQuotationData.sol"; +import "../../interfaces/IStakingNFT.sol"; import "../../interfaces/IStakingPool.sol"; import "../../interfaces/ITokenController.sol"; import "../../libraries/SafeUintCast.sol"; @@ -42,17 +43,20 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { IQuotationData public immutable quotationData; address public immutable claimsReward; address public immutable stakingPoolFactory; + IStakingNFT public immutable stakingNFT; constructor( address quotationDataAddress, address claimsRewardAddress, address stakingPoolFactoryAddress, - address tokenAddress + address tokenAddress, + address stakingNFTAddress ) { quotationData = IQuotationData(quotationDataAddress); claimsReward = claimsRewardAddress; stakingPoolFactory = stakingPoolFactoryAddress; token = INXMToken(tokenAddress); + stakingNFT = IStakingNFT(stakingNFTAddress); } /* ========== DEPENDENCIES ========== */ @@ -73,27 +77,32 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { return IPool(internalContracts[uint(ID.P1)]); } + function stakingPool(uint poolId) internal view returns (IStakingPool) { + return IStakingPool(_stakingPool(poolId)); + } + + function _stakingPool(uint poolId) internal view returns (address) { + return StakingPoolLibrary.getAddress(stakingPoolFactory, poolId); + } + function changeDependentContractAddress() public override { + internalContracts[uint(ID.PS)] = master.getLatestAddress("PS"); internalContracts[uint(ID.AS)] = master.getLatestAddress("AS"); internalContracts[uint(ID.GV)] = master.getLatestAddress("GV"); internalContracts[uint(ID.P1)] = master.getLatestAddress("P1"); } - /** - * @dev to change the operator address - * @param _newOperator is the new address of operator - */ + /// @dev Changes the operator address. + /// @param _newOperator The new address of the operator. function changeOperator(address _newOperator) public override onlyGovernance { token.changeOperator(_newOperator); } - /** - * @dev Proxies token transfer through this contract to allow staking when members are locked for voting - * @param _from Source address - * @param _to Destination address - * @param _value Amount to transfer - */ + /// @dev Proxies token transfer through this contract to allow staking when members are locked for voting. + /// @param _from The source address. + /// @param _to The destination address. + /// @param _value The amount to transfer. function operatorTransfer( address _from, address _to, @@ -107,48 +116,39 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { return true; } - /** - * @dev burns tokens of an address - * @param _of is the address to burn tokens of - * @param amount is the amount to burn - * @return the boolean status of the burning process - */ + /// @dev Burns tokens of an address. + /// @param _of The address to burn tokens of. + /// @param amount The amount to burn. + /// @return The boolean status of the burning process. function burnFrom(address _of, uint amount) public override onlyInternal returns (bool) { return token.burnFrom(_of, amount); } - /** - * @dev Adds an address to whitelist maintained in the contract - * @param _member address to add to whitelist - */ + /// @dev Adds an address to the whitelist maintained in the contract. + /// @param _member The address to add to the whitelist. function addToWhitelist(address _member) public virtual override onlyInternal { token.addToWhiteList(_member); } - /** - * @dev Removes an address from the whitelist in the token - * @param _member address to remove - */ + /// @dev Removes an address from the whitelist in the token. + /// @param _member The address to remove. function removeFromWhitelist(address _member) public override onlyInternal { token.removeFromWhiteList(_member); } - /** - * @dev Mints new tokens for an address and checks if the address is a member - * @param _member address to send the minted tokens to - * @param _amount number of tokens to mint - */ + /// @dev Mints new tokens for an address and checks if the address is a member. + /// @param _member The address to send the minted tokens to. + /// @param _amount The number of tokens to mint. function mint(address _member, uint _amount) public override onlyInternal { _mint(_member, _amount); } - /** - * @dev Internal function to mint new tokens for an address and checks if the address is a member - * @dev Other internal functions in this contract should use _mint and never token.mint directly - * @param _member address to send the minted tokens to - * @param _amount number of tokens to mint - */ + /// @dev Internal function to mint new tokens for an address and checks if the address is a member. + /// @dev Other internal functions in this contract should use _mint and never token.mint directly. + /// @param _member The address to send the minted tokens to. + /// @param _amount The number of tokens to mint. function _mint(address _member, uint _amount) internal { + require( _member == address(this) || token.whiteListed(_member), "TokenController: Address is not a member" @@ -156,37 +156,39 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { token.mint(_member, _amount); } - /** - * @dev Lock the user's tokens - * @param _of user's address. - */ + /// @dev Locks the user's tokens. + /// @param _of The user's address. + /// @param _days The number of days to lock the tokens. function lockForMemberVote(address _of, uint _days) public override onlyInternal { token.lockForMemberVote(_of, _days); } - /** - * @dev Unlocks the withdrawable tokens against CLA of a specified addresses - * @param users Addresses of users for whom the tokens are unlocked - */ - function withdrawClaimAssessmentTokens(address[] calldata users) external whenNotPaused { + /// @dev Unlocks the withdrawable tokens against CLA for specified addresses. + /// @param users The addresses of users for whom the tokens are unlocked. + function withdrawClaimAssessmentTokens(address[] calldata users) external override whenNotPaused { + for (uint256 i = 0; i < users.length; i++) { - if (locked[users[i]]["CLA"].claimed) { - continue; - } - uint256 amount = locked[users[i]]["CLA"].amount; + _withdrawClaimAssessmentTokensForUser(users[i]); + } + } + + /// @dev Internal function to withdraw claim assessment tokens for a user. + /// @param user The user's address. + function _withdrawClaimAssessmentTokensForUser(address user) internal whenNotPaused { + + if (!locked[user]["CLA"].claimed) { + uint256 amount = locked[user]["CLA"].amount; if (amount > 0) { - locked[users[i]]["CLA"].claimed = true; - emit Unlocked(users[i], "CLA", amount); - token.transfer(users[i], amount); + locked[user]["CLA"].claimed = true; + emit Unlocked(user, "CLA", amount); + token.transfer(user, amount); } } } - /** - * @dev Updates Uint Parameters of a code - * @param code whose details we want to update - * @param value value to set - */ + /// @dev Updates Uint Parameters of a code. + /// @param code The code whose details we want to update. + /// @param value The value to set. function updateUintParameters(bytes8 code, uint value) external view onlyGovernance { // silence compiler warnings code; @@ -194,26 +196,29 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { revert("TokenController: invalid param code"); } + /// @notice Retrieves the reasons why a user's tokens were locked. + /// @param _of The address of the user whose lock reasons are being retrieved. + /// @return reasons An array of reasons (as bytes32) for the token lock. function getLockReasons(address _of) external override view returns (bytes32[] memory reasons) { return lockReason[_of]; } + /// @notice Returns the total supply of the NXM token. + /// @return The total supply of the NXM token. function totalSupply() public override view returns (uint256) { return token.totalSupply(); } - /// Returns the base voting power. It is used in governance and snapshot voting. - /// Includes the delegated tokens via staking pools. - /// + /// @notice Returns the base voting power. It is used in governance and snapshot voting. + /// Includes the delegated tokens via staking pools. /// @param _of The member address for which the base voting power is calculated. function totalBalanceOf(address _of) public override view returns (uint) { return _totalBalanceOf(_of, true); } - /// Returns the base voting power. It is used in governance and snapshot voting. - /// Does not include the delegated tokens via staking pools in order to act as a fallback if - /// voting including delegations fails for whatever reason. - /// + /// @notice Returns the base voting power. It is used in governance and snapshot voting. + /// @dev Does not include the delegated tokens via staking pools in order to act as a fallback if + /// voting including delegations fails for whatever reason. /// @param _of The member address for which the base voting power is calculated. function totalBalanceOfWithoutDelegations(address _of) public override view returns (uint) { return _totalBalanceOf(_of, false); @@ -246,87 +251,117 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { return amount; } - /// Returns the NXM price in ETH. To be use by external protocols. - /// + /// @notice Returns the NXM price in ETH. To be use by external protocols. /// @dev Intended for external protocols - this is a proxy and the contract address won't change function getTokenPrice() public override view returns (uint tokenPrice) { // get spot price from ramm return pool().getTokenPrice(); } - /// Withdraws governance rewards for the given member address - /// @dev This function requires a batchSize that fits in one block. It cannot be 0. + /// @notice Withdraws governance rewards for the given member address + /// @param memberAddress The address of the member whose governance rewards are to be withdrawn. + /// @param batchSize The maximum number of iterations to avoid unbounded loops when withdrawing governance rewards. + /// Cannot be 0 and must fit in one block function withdrawGovernanceRewards( address memberAddress, uint batchSize ) public whenNotPaused { + uint governanceRewards = governance().claimReward(memberAddress, batchSize); require(governanceRewards > 0, "TokenController: No withdrawable governance rewards"); + token.transfer(memberAddress, governanceRewards); } - /// Withdraws governance rewards to the destination address. It can only be called by the owner - /// of the rewards. - /// @dev This function requires a batchSize that fits in one block. It cannot be 0. + /// @notice Withdraws governance rewards to the destination address. It can only be called by the owner + /// of the rewards. + /// @param destination The address to which the governance rewards will be transferred. + /// @param batchSize The maximum number of iterations to avoid unbounded loops when withdrawing governance rewards. + /// Cannot be 0 and must fit in one block function withdrawGovernanceRewardsTo( address destination, uint batchSize ) public whenNotPaused { + uint governanceRewards = governance().claimReward(msg.sender, batchSize); require(governanceRewards > 0, "TokenController: No withdrawable governance rewards"); + token.transfer(destination, governanceRewards); } + /// @notice Retrieves the pending rewards for a given member. + /// @param member The address of the member whose pending rewards are to be retrieved. + /// @return The total amount of pending rewards for the given member. function getPendingRewards(address member) public view returns (uint) { + (uint totalPendingAmountInNXM,,) = assessment().getRewards(member); uint governanceRewards = governance().getPendingReward(member); + return totalPendingAmountInNXM + governanceRewards; } - /// Function used to claim all pending rewards in one tx. It can be used to selectively withdraw - /// rewards. - /// - /// @param forUser The address for whom the governance and/or assessment rewards are - /// withdrawn. - /// @param fromGovernance When true, governance rewards are withdrawn. - /// @param fromAssessment When true, assessment rewards are withdrawn. - /// @param batchSize The maximum number of iterations to avoid unbounded loops when - /// withdrawing governance and/or assessment rewards. - function withdrawPendingRewards( - address forUser, - bool fromGovernance, - bool fromAssessment, - uint batchSize + /// @notice Withdraws NXM from the Nexus platform based on specified options. + /// @dev Ensure the NXM is available and not locked before withdrawal. Only set flags in `WithdrawNxmOptions` for + /// withdrawable NXM. Reverts if some of the NXM being withdrawn is locked or unavailable. + /// @param stakingPoolDeposits Details for withdrawing staking pools stake and rewards. Empty array to skip + /// @param stakingPoolManagerRewards Details for withdrawing staking pools manager rewards. Empty array to skip + /// @param govRewardsBatchSize The maximum number of iterations to avoid unbounded loops when withdrawing + /// governance rewards. + /// @param withdrawAssessment Options specifying assesment withdrawals, set flags to true to include + /// specific assesment stake or rewards withdrawal. + function withdrawNXM( + WithdrawAssessment calldata withdrawAssessment, + StakingPoolDeposit[] calldata stakingPoolDeposits, + StakingPoolManagerReward[] calldata stakingPoolManagerRewards, + uint assessmentRewardsBatchSize, + uint govRewardsBatchSize ) external whenNotPaused { - if (fromAssessment) { - assessment().withdrawRewards(forUser, batchSize.toUint104()); + // assessment stake + if (withdrawAssessment.stake) { + assessment().unstakeAllFor(msg.sender); + } + + // assessment rewards + if (withdrawAssessment.rewards) { + // pass in 0 batchSize to withdraw ALL Assessment rewards + assessment().withdrawRewards(msg.sender, assessmentRewardsBatchSize.toUint104()); } - if (fromGovernance) { - uint governanceRewards = governance().claimReward(forUser, batchSize); - require(governanceRewards > 0, "TokenController: No withdrawable governance rewards"); - token.transfer(forUser, governanceRewards); + // governance rewards + uint governanceRewards = governance().claimReward(msg.sender, govRewardsBatchSize); + if (governanceRewards > 0) { + token.transfer(msg.sender, governanceRewards); + } + + // staking pool rewards and stake + for (uint i = 0; i < stakingPoolDeposits.length; i++) { + uint tokenId = stakingPoolDeposits[i].tokenId; + uint poolId = stakingNFT.stakingPoolOf(tokenId); + stakingPool(poolId).withdraw(tokenId, true, true, stakingPoolDeposits[i].trancheIds); + } + + // staking pool manager rewards + for (uint i = 0; i < stakingPoolManagerRewards.length; i++) { + uint poolId = stakingPoolManagerRewards[i].poolId; + stakingPool(poolId).withdraw(0, false, true, stakingPoolManagerRewards[i].trancheIds); } } - /** - * @dev Returns tokens locked for a specified address for a - * specified reason - * - * @param _of The address whose tokens are locked - * @param _reason The reason to query the lock tokens for - */ + /// @dev Returns tokens locked for a specified address for a specified reason + /// @param _of The address whose tokens are locked + /// @param _reason The reason to query the locked tokens for function tokensLocked( address _of, bytes32 _reason ) public view returns (uint256 amount) { + if (!locked[_of][_reason].claimed) { amount = locked[_of][_reason].amount; } } - // Can be removed once all cover notes are withdrawn + /// @dev Can be removed once all cover notes are withdrawn function getWithdrawableCoverNotes( address coverOwner ) public view returns ( @@ -361,12 +396,12 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { } } - // Can be removed once all cover notes are withdrawn + /// @dev Can be removed once all cover notes are withdrawn function withdrawCoverNote( address user, uint[] calldata coverIds, uint[] calldata indexes - ) external whenNotPaused override { + ) public whenNotPaused override { uint reasonCount = lockReason[user].length; require(reasonCount > 0, "TokenController: No locked cover notes found"); @@ -403,18 +438,30 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { token.transfer(user, totalAmount); } + /// @notice Retrieves the manager of a specific staking pool. + /// @param poolId The ID of the staking pool. + /// @return The address of the staking pool manager. function getStakingPoolManager(uint poolId) external override view returns (address) { return stakingPoolManagers[poolId]; } + /// @notice Retrieves the staking pools managed by a specific manager. + /// @param manager The address of the manager. + /// @return An array of staking pool IDs managed by the specified manager. function getManagerStakingPools(address manager) external override view returns (uint[] memory) { return managerStakingPools[manager]; } + /// @notice Checks if a given address is a staking pool manager. + /// @param member The address to check. function isStakingPoolManager(address member) external override view returns (bool) { return managerStakingPools[member].length > 0; } + /// @notice Retrieves the ownership offer details for a specific staking pool. + /// @param poolId The ID of the staking pool. + /// @return proposedManager The address of the proposed new manager. + /// @return deadline The deadline for accepting the ownership offer. function getStakingPoolOwnershipOffer( uint poolId ) external override view returns (address proposedManager, uint deadline) { @@ -424,8 +471,8 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { ); } - /// Transfer ownership of all staking pools managed by a member to a new address. Used when switching membership. - /// + /// @notice Transfer ownership of all staking pools managed by a member to a new address. + /// @dev Used when switching membership. /// @param from address of the member whose pools are being transferred /// @param to the new address of the member function transferStakingPoolsOwnership(address from, address to) external override onlyInternal { @@ -474,18 +521,16 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { stakingPoolManagers[poolId] = manager; } - /// Transfers the ownership of a staking pool to a new address - /// Used by PooledStaking during the migration - /// + /// @notice Transfers the ownership of a staking pool to a new address + /// @dev Used by PooledStaking during the migration /// @param poolId id of the staking pool /// @param manager address of the new manager of the staking pool function assignStakingPoolManager(uint poolId, address manager) external override onlyInternal { _assignStakingPoolManager(poolId, manager); } - /// Creates a ownership transfer offer for a staking pool - /// The offer can be accepted by the proposed manager before the deadline expires - /// + /// @notice Creates a ownership transfer offer for a staking pool + /// @dev The offer can be accepted by the proposed manager before the deadline expires /// @param poolId id of the staking pool /// @param proposedManager address of the proposed manager /// @param deadline timestamp after which the offer expires @@ -494,13 +539,14 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { address proposedManager, uint deadline ) external override { + require(msg.sender == stakingPoolManagers[poolId], "TokenController: Caller is not staking pool manager"); require(block.timestamp < deadline, "TokenController: Deadline cannot be in the past"); + stakingPoolOwnershipOffers[poolId] = StakingPoolOwnershipOffer(proposedManager, deadline.toUint96()); } - /// Accepts a staking pool ownership offer - /// + /// @notice Accepts a staking pool ownership offer /// @param poolId id of the staking pool function acceptStakingPoolOwnershipOffer(uint poolId) external override { @@ -522,56 +568,92 @@ contract TokenController is ITokenController, LockHandler, MasterAwareV2 { ); _assignStakingPoolManager(poolId, msg.sender); + delete stakingPoolOwnershipOffers[poolId]; } - /// Cancels a staking pool ownership offer - /// + /// @notice Cancels a staking pool ownership offer /// @param poolId id of the staking pool function cancelStakingPoolOwnershipOffer(uint poolId) external override { + require(msg.sender == stakingPoolManagers[poolId], "TokenController: Caller is not staking pool manager"); + delete stakingPoolOwnershipOffers[poolId]; } - function _stakingPool(uint poolId) internal view returns (address) { - return StakingPoolLibrary.getAddress(stakingPoolFactory, poolId); - } + /// @notice Mints a specified amount of NXM rewards for a staking pool. + /// @dev Only callable by the staking pool associated with the given poolId + /// @param amount The amount of NXM to mint. + /// @param poolId The ID of the staking pool. + function mintStakingPoolNXMRewards(uint amount, uint poolId) external override { - function mintStakingPoolNXMRewards(uint amount, uint poolId) external { require(msg.sender == _stakingPool(poolId), "TokenController: Caller not a staking pool"); + _mint(address(this), amount); + stakingPoolNXMBalances[poolId].rewards += amount.toUint128(); } - function burnStakingPoolNXMRewards(uint amount, uint poolId) external { + /// @notice Burns a specified amount of NXM rewards from a staking pool. + /// @dev Only callable by the staking pool associated with the given poolId + /// @param amount The amount of NXM to burn. + /// @param poolId The ID of the staking pool. + function burnStakingPoolNXMRewards(uint amount, uint poolId) external override { + require(msg.sender == _stakingPool(poolId), "TokenController: Caller not a staking pool"); + stakingPoolNXMBalances[poolId].rewards -= amount.toUint128(); + token.burn(amount); } - function depositStakedNXM(address from, uint amount, uint poolId) external { + /// @notice Deposits a specified amount of staked NXM from the member into a staking pool. + /// @dev Only callable by the staking pool associated with the given poolId + /// @param from The member address from which the NXM is transferred. + /// @param amount The amount of NXM to deposit. + /// @param poolId The ID of the staking pool. + function depositStakedNXM(address from, uint amount, uint poolId) external override { + require(msg.sender == _stakingPool(poolId), "TokenController: Caller not a staking pool"); + stakingPoolNXMBalances[poolId].deposits += amount.toUint128(); + token.operatorTransfer(from, amount); } + /// @notice Withdraws a specified amount of staked NXM and rewards from a staking pool to the member address + /// @dev Only callable by the staking pool associated with the given poolId + /// @param to The address to which the NXM and rewards are transferred. + /// @param stakeToWithdraw The amount of staked NXM to withdraw. + /// @param rewardsToWithdraw The amount of rewards to withdraw. + /// @param poolId The ID of the staking pool. function withdrawNXMStakeAndRewards( address to, uint stakeToWithdraw, uint rewardsToWithdraw, uint poolId - ) external { + ) external override { + require(msg.sender == _stakingPool(poolId), "TokenController: Caller not a staking pool"); StakingPoolNXMBalances memory poolBalances = stakingPoolNXMBalances[poolId]; + poolBalances.deposits -= stakeToWithdraw.toUint128(); poolBalances.rewards -= rewardsToWithdraw.toUint128(); stakingPoolNXMBalances[poolId] = poolBalances; + token.transfer(to, stakeToWithdraw + rewardsToWithdraw); } - function burnStakedNXM(uint amount, uint poolId) external { + /// @notice Burns a specified amount of staked NXM from a staking pool. + /// @dev Only callable by the staking pool associated with the given poolId + /// @param amount The amount of staked NXM to burn. + /// @param poolId The ID of the staking pool. + function burnStakedNXM(uint amount, uint poolId) external override { + require(msg.sender == _stakingPool(poolId), "TokenController: Caller not a staking pool"); + stakingPoolNXMBalances[poolId].deposits -= amount.toUint128(); + token.burn(amount); } } diff --git a/deployments/package-lock.json b/deployments/package-lock.json index 501c598b39..ca4eb4af40 100644 --- a/deployments/package-lock.json +++ b/deployments/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nexusmutual/deployments", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nexusmutual/deployments", - "version": "2.7.1", + "version": "2.8.0", "license": "GPL-3.0" } } diff --git a/deployments/package.json b/deployments/package.json index 4c8ad433c6..e127a214c8 100644 --- a/deployments/package.json +++ b/deployments/package.json @@ -1,6 +1,6 @@ { "name": "@nexusmutual/deployments", - "version": "2.7.1", + "version": "2.8.0", "description": "Nexus Mutual deployed contract addresses and abis", "typings": "./dist/index.d.ts", "main": "./dist/index.js", diff --git a/deployments/src/addresses.json b/deployments/src/addresses.json index e3187dbcb9..7d259ac6a8 100644 --- a/deployments/src/addresses.json +++ b/deployments/src/addresses.json @@ -32,7 +32,7 @@ "StakingNFT": "0xcafea508a477D94c502c253A58239fb8F948e97f", "StakingPoolFactory": "0xcafeafb97BF8831D95C0FC659b8eB3946B101CB3", "StakingProducts": "0xcafea573fBd815B5f59e8049E71E554bde3477E4", - "StakingViewer": "0xcafea570fA5DFfAdc471528C102114bC233b683A", + "StakingViewer": "0xcafea5E8a7a54dd14Bb225b66C7a016dfd7F236b", "SwapOperator": "0xcafea3cA5366964A102388EAd5f3eBb0769C46Cb", "TokenController": "0x5407381b6c251cFd498ccD4A1d877739CB7960B8", "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", diff --git a/package-lock.json b/package-lock.json index d7ecf37f52..a737b944bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "nexusmutual", - "version": "2.7.1", + "version": "2.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nexusmutual", - "version": "2.7.1", + "version": "2.8.0", "license": "GPL-3.0", "dependencies": { - "@nexusmutual/deployments": "^2.6.2", + "@nexusmutual/deployments": "^2.8.0", "@nexusmutual/ethers-v5-aws-kms-signer": "^0.0.1", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", @@ -30,6 +30,7 @@ "@tenderly/hardhat-tenderly": "^1.7.7", "@typechain/ethers-v5": "^10.1.0", "@typechain/hardhat": "^6.1.5", + "async-sema": "^3.1.1", "chai": "^4.3.6", "coveralls": "^3.1.1", "csv-parse": "^5.3.4", @@ -1963,14 +1964,16 @@ "dev": true }, "node_modules/@nexusmutual/deployments": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.6.2.tgz", - "integrity": "sha512-FQCeO0MYlaz2rQjWrPfRQgeMBwh9B2Zk4F5g4eVMdKeeO59efTC0ONg3QXbql+gYRdXnbxJyFnJMC4nzmL2CsQ==" + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.8.0.tgz", + "integrity": "sha512-6b1bC7TQ8YiqWVVj4Du//q46SWyVjJHZoRAHCUzSIMvYexkqyRu8tVootMVTVL/PaCWNkkHVlVPYs1iZTAS93g==", + "license": "GPL-3.0" }, "node_modules/@nexusmutual/ethers-v5-aws-kms-signer": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@nexusmutual/ethers-v5-aws-kms-signer/-/ethers-v5-aws-kms-signer-0.0.1.tgz", "integrity": "sha512-6pAxrUiNPKWnyJfsKbuM5zSdODpoWyeIquFEC+mTxyutYldR6aJq9AdHN5ntI7gY8ehGr3gAEcasOsmGndsSFQ==", + "license": "MIT", "dependencies": { "@aws-sdk/client-kms": "^3.533.0", "@ethersproject/providers": "^5.7.2", @@ -4337,6 +4340,13 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -16703,9 +16713,9 @@ "dev": true }, "@nexusmutual/deployments": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.6.2.tgz", - "integrity": "sha512-FQCeO0MYlaz2rQjWrPfRQgeMBwh9B2Zk4F5g4eVMdKeeO59efTC0ONg3QXbql+gYRdXnbxJyFnJMC4nzmL2CsQ==" + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@nexusmutual/deployments/-/deployments-2.8.0.tgz", + "integrity": "sha512-6b1bC7TQ8YiqWVVj4Du//q46SWyVjJHZoRAHCUzSIMvYexkqyRu8tVootMVTVL/PaCWNkkHVlVPYs1iZTAS93g==" }, "@nexusmutual/ethers-v5-aws-kms-signer": { "version": "0.0.1", @@ -18626,6 +18636,12 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, + "async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "dev": true + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", diff --git a/package.json b/package.json index 0997f6ce7b..ba0f44b99a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nexusmutual", - "version": "2.7.1", + "version": "2.8.0", "description": "NexusMutual smart contracts", "repository": { "type": "git", @@ -20,7 +20,7 @@ }, "homepage": "https://github.com/NexusMutual/smart-contracts", "dependencies": { - "@nexusmutual/deployments": "^2.6.2", + "@nexusmutual/deployments": "^2.8.0", "@nexusmutual/ethers-v5-aws-kms-signer": "^0.0.1", "@nomicfoundation/hardhat-network-helpers": "^1.0.8", "@openzeppelin/contracts-v4": "npm:@openzeppelin/contracts@^4.7.3", @@ -41,6 +41,7 @@ "@tenderly/hardhat-tenderly": "^1.7.7", "@typechain/ethers-v5": "^10.1.0", "@typechain/hardhat": "^6.1.5", + "async-sema": "^3.1.1", "chai": "^4.3.6", "coveralls": "^3.1.1", "csv-parse": "^5.3.4", diff --git a/scripts/create2/deploy.js b/scripts/create2/deploy.js index 39bd4cb3ba..0cd263b877 100644 --- a/scripts/create2/deploy.js +++ b/scripts/create2/deploy.js @@ -1,9 +1,12 @@ const { artifacts, ethers, run } = require('hardhat'); const { keccak256 } = require('ethereum-cryptography/keccak'); const { bytesToHex, hexToBytes } = require('ethereum-cryptography/utils'); +const linker = require('solc/linker'); const { getSigner, SIGNER_TYPE } = require('./get-signer'); +const ADDRESS_REGEX = /^0x[a-f0-9]{40}$/i; + const usage = () => { console.log(` Usage: @@ -24,12 +27,14 @@ const usage = () => { [gas price] Base fee in gwei. This is a required parameter. --priority-fee, -p MINER_TIP [gas price] Miner tip in gwei. Default: 2 gwei. This is a required parameter. - --gas-limit, -l GAS_LIMIT + --gas-limit, -g GAS_LIMIT Gas limit for the tx. --kms, -k Use AWS KMS to sign the transaction. --help, -h Print this help message. + --library, -l CONTRACT_NAME:ADDRESS + Link an external library. `); }; @@ -38,6 +43,7 @@ const parseArgs = async args => { constructorArgs: [], priorityFee: '2', kms: false, + libraries: {}, }; const argsArray = args.slice(2); @@ -63,7 +69,7 @@ const parseArgs = async args => { if (['--address', '-a'].includes(arg)) { opts.address = argsArray.shift(); - if (opts.address.match(/^0x[^a-f0-9]{40}$/i)) { + if (!opts.address.match(ADDRESS_REGEX)) { throw new Error(`Invalid address: ${opts.address}`); } continue; @@ -71,7 +77,7 @@ const parseArgs = async args => { if (['--factory-address', '-f'].includes(arg)) { opts.factory = argsArray.shift(); - if (!(opts.factory || '').match(/0x[a-f0-9]{40}/i)) { + if (!(opts.factory || '').match(ADDRESS_REGEX)) { throw new Error(`Invalid factory address: ${opts.factory}`); } continue; @@ -98,11 +104,25 @@ const parseArgs = async args => { continue; } - if (['--gas-limit', '-l'].includes(arg)) { + if (['--gas-limit', '-g'].includes(arg)) { opts.gasLimit = parseInt(argsArray.shift(), 10); continue; } + if (['--library', '-l'].includes(arg)) { + const libArg = argsArray.shift(); + + const [contractName, address] = libArg.split(':'); + if (!contractName || !address || !address.match(ADDRESS_REGEX)) { + throw new Error(`Invalid library format: ${libArg}. Expected format is CONTRACT_NAME:ADDRESS`); + } + + const { sourceName } = await artifacts.readArtifact(contractName); + opts.libraries[`${sourceName}:${contractName}`] = address; + + continue; + } + positionalArgs.push(arg); } @@ -128,11 +148,14 @@ const parseArgs = async args => { }; const getDeploymentBytecode = async options => { - const { abi, bytecode } = await artifacts.readArtifact(options.contract); + const { abi, bytecode: initialBytecode } = await artifacts.readArtifact(options.contract); + + const bytecode = initialBytecode.includes('__$') + ? linker.linkBytecode(initialBytecode, options.libraries) + : initialBytecode; - // FIXME: implement library linking if (bytecode.includes('__$')) { - throw new Error('Library linking is not implemented yet'); + throw new Error('Missing external library address link. Please use --library, -l option'); } const constructorAbi = abi.find(({ type }) => type === 'constructor'); diff --git a/scripts/create2/find-salt.js b/scripts/create2/find-salt.js index c12b2fba2e..b895637cf6 100644 --- a/scripts/create2/find-salt.js +++ b/scripts/create2/find-salt.js @@ -1,9 +1,12 @@ +const path = require('node:path'); + const { artifacts, ethers, run } = require('hardhat'); const { keccak256 } = require('ethereum-cryptography/keccak'); const { bytesToHex, hexToBytes } = require('ethereum-cryptography/utils'); +const linker = require('solc/linker'); const workerpool = require('workerpool'); -const path = require('path'); +const ADDRESS_REGEX = /^0x[a-f0-9]{40}$/i; const Position = { start: 'start', end: 'end', @@ -32,6 +35,8 @@ const usage = () => { Start the search from this salt. --help, -h Print this help message. + --library, -l CONTRACT_NAME:ADDRESS + Link an external library. `); }; @@ -42,6 +47,7 @@ const parseArgs = async args => { ignoreCase: false, constructorArgs: [], salt: 0, + libraries: {}, }; const argsArray = args.slice(2); @@ -95,12 +101,26 @@ const parseArgs = async args => { if (['--factory-address', '-f'].includes(arg)) { opts.factory = argsArray.shift(); - if (!(opts.factory || '').match(/0x[a-f0-9]{40}/i)) { + if (!(opts.factory || '').match(ADDRESS_REGEX)) { throw new Error(`Invalid factory address: ${opts.factory}`); } continue; } + if (['--library', '-l'].includes(arg)) { + const libArg = argsArray.shift(); + + const [contractName, address] = libArg.split(':'); + if (!contractName || !address || !address.match(ADDRESS_REGEX)) { + throw new Error(`Invalid library format: ${libArg}. Expected format is CONTRACT_NAME:ADDRESS`); + } + + const { sourceName } = await artifacts.readArtifact(contractName); + opts.libraries[`${sourceName}:${contractName}`] = address; + + continue; + } + positionalArgs.push(arg); } @@ -122,11 +142,14 @@ const parseArgs = async args => { }; const getDeploymentBytecode = async options => { - const { abi, bytecode } = await artifacts.readArtifact(options.contract); + const { abi, bytecode: initialBytecode } = await artifacts.readArtifact(options.contract); + + const bytecode = initialBytecode.includes('__$') + ? linker.linkBytecode(initialBytecode, options.libraries) + : initialBytecode; - // FIXME: implement library linking if (bytecode.includes('__$')) { - throw new Error('Library linking is not implemented yet'); + throw new Error('Missing external library address link. Please use --library, -l option'); } const constructorAbi = abi.find(({ type }) => type === 'constructor'); diff --git a/scripts/governance/get-decoded-action-data.js b/scripts/governance/get-decoded-action-data.js new file mode 100644 index 0000000000..ebc0b9abcf --- /dev/null +++ b/scripts/governance/get-decoded-action-data.js @@ -0,0 +1,104 @@ +const util = require('node:util'); +const { ethers } = require('hardhat'); + +const { defaultAbiCoder, toUtf8String } = ethers.utils; + +const HEX_REGEX = /^0x[a-f0-9]+$/i; +const CATEGORIES_HANDLERS = { + 29: decodeReleaseNewContractCode, +}; + +const usage = () => { + console.log(` + Usage: + get-decoded-action-data [OPTION] + + Options: + --category-id, -i CATEGORY_ID + The category id of the governance proposal. + --data, -d HEX_ACTION_DATA + The action data to decode in hex format + --help, -h + Print this help message. + `); +}; + +const parseArgs = async args => { + const opts = {}; + + const argsArray = args.slice(2); + + if (argsArray.length === 0) { + usage(); + process.exit(1); + } + + while (argsArray.length) { + const arg = argsArray.shift(); + + if (['--help', '-h'].includes(arg)) { + usage(); + process.exit(); + } + + if (['--category-id', '-i'].includes(arg)) { + opts.category = argsArray.shift(); + if (!CATEGORIES_HANDLERS[opts.category]) { + const supportedCategories = Object.keys(CATEGORIES_HANDLERS).join(', '); + throw new Error(`Category ${opts.category} not yet supported. Supported categories: ${supportedCategories}`); + } + continue; + } + + if (['--data', '-d'].includes(arg)) { + const hexData = argsArray.shift(); + if (!hexData.match(HEX_REGEX)) { + throw new Error('Invalid hex data'); + } + opts.data = hexData; + } + } + + if (!opts.category) { + throw new Error('Missing required argument: --category-id'); + } + + if (!opts.data) { + throw new Error('Missing required argument: --data, -d'); + } + + return opts; +}; + +async function main() { + const opts = await parseArgs(process.argv).catch(err => { + console.error(`Error: ${err.message}`); + process.exit(1); + }); + + CATEGORIES_HANDLERS[opts.category](opts); +} + +/* Category Handlers */ + +function decodeReleaseNewContractCode(options) { + const [codes, addresses] = defaultAbiCoder.decode(['bytes2[]', 'address[]'], options.data); + const contractCodesUtf8 = codes.map(code => toUtf8String(code)); + + console.log(`Decoded Release New Contract Code (29):\n${util.inspect([contractCodesUtf8, addresses], { depth: 2 })}`); +} + +if (require.main === module) { + main() + .then(() => { + process.exit(0); + }) + .catch(e => { + console.log('Unhandled error encountered: ', e.stack); + process.exit(1); + }); +} + +module.exports = { + decodeReleaseNewContractCode, +}; diff --git a/scripts/governance/get-encoded-action-data.js b/scripts/governance/get-encoded-action-data.js new file mode 100644 index 0000000000..526a49cbc7 --- /dev/null +++ b/scripts/governance/get-encoded-action-data.js @@ -0,0 +1,145 @@ +const { ethers } = require('hardhat'); + +const { defaultAbiCoder, toUtf8Bytes } = ethers.utils; + +const ADDRESS_REGEX = /^0x[a-f0-9]{40}$/i; +const CATEGORIES_HANDLERS = { + 29: encodeReleaseNewContractCode, +}; + +const usage = () => { + console.log(` + Usage: + get-encoded-action-data [OPTION] + + Options: + --category-id, -i CATEGORY_ID + The category id of the governance proposal. + --contract-codes, -c CONTRACT_CODES + An array of utf-8 contract codes in JSON format. + --addresses, -a ADDRESSES + An array of addresses corresponding the contract does in JSON format. + --help, -h + Print this help message. + `); +}; + +const isValidJSON = str => { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +}; + +const parseArgs = async args => { + const opts = {}; + + const argsArray = args.slice(2); + + if (argsArray.length === 0) { + usage(); + process.exit(1); + } + + while (argsArray.length) { + const arg = argsArray.shift(); + + if (['--help', '-h'].includes(arg)) { + usage(); + process.exit(); + } + + if (['--category-id', '-i'].includes(arg)) { + opts.category = argsArray.shift(); + if (!CATEGORIES_HANDLERS[opts.category]) { + const supportedCategories = Object.keys(CATEGORIES_HANDLERS).join(', '); + throw new Error(`Category ${opts.category} not yet supported. Supported categories: ${supportedCategories}`); + } + continue; + } + + if (['--contract-codes', '-c'].includes(arg)) { + const contractCodesString = argsArray.shift(); + + if (!isValidJSON(contractCodesString)) { + throw new Error('-c CONTRACT_CODES must be in JSON format'); + } + + const contractCodes = JSON.parse(contractCodesString); + contractCodes.forEach(code => { + if (code.length !== 2) { + throw new Error(`Invalid contract code ${code}`); + } + }); + + opts.contractCodes = contractCodes; + } + + if (['--addresses', '-a'].includes(arg)) { + const addressesString = argsArray.shift(); + if (!isValidJSON(addressesString)) { + throw new Error('-a ADDRESSES must be in JSON format'); + } + const addresses = JSON.parse(addressesString); + addresses.forEach(address => { + if (!address.match(ADDRESS_REGEX)) { + throw new Error(`Invalid address ${address}`); + } + }); + opts.addresses = addresses; + } + } + + if (!opts.category) { + throw new Error('Missing required argument: --category-id, -c'); + } + + if (opts.category === '29') { + if (!opts.contractCodes || !opts.addresses) { + throw new Error('Contract codes and addresses are required for category 29'); + } + } + + return opts; +}; + +async function main() { + const opts = await parseArgs(process.argv).catch(err => { + console.error(`Error: ${err.message}`); + process.exit(1); + }); + + CATEGORIES_HANDLERS[opts.category](opts); +} + +/** + * Converts UTF-8 contract codes to bytes in hex format + */ +const getContractCodeHexBytes = code => `0x${Buffer.from(toUtf8Bytes(code)).toString('hex')}`; + +/* Category Handlers */ + +function encodeReleaseNewContractCode(options) { + const contractCodeBytes = options.contractCodes.map(getContractCodeHexBytes); + const decodedAction = defaultAbiCoder.encode(['bytes2[]', 'address[]'], [contractCodeBytes, options.addresses]); + + console.log(`Encoded Release New Contract Code (29):\n${decodedAction}`); +} + +if (require.main === module) { + main() + .then(() => { + process.exit(0); + }) + .catch(e => { + console.log('Unhandled error encountered: ', e.stack); + process.exit(1); + }); +} + +module.exports = { + getContractCodeHexBytes, + encodeReleaseNewContractCode, +}; diff --git a/scripts/staking-fees.js b/scripts/staking-fees.js new file mode 100644 index 0000000000..da09d56fce --- /dev/null +++ b/scripts/staking-fees.js @@ -0,0 +1,65 @@ +const { ethers } = require('hardhat'); +const { addresses, Cover, StakingPool, StakingPoolFactory } = require('@nexusmutual/deployments'); + +const { formatEther, formatUnits } = ethers.utils; +const TRANCHE_DURATION = 91 * 24 * 3600; // 91 days +const sum = arr => arr.reduce((a, b) => a.add(b), ethers.constants.Zero); + +async function main() { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const currentTrancheId = Math.floor(now / TRANCHE_DURATION); + + const cover = await ethers.getContractAt(Cover, addresses.Cover); + const factory = await ethers.getContractAt(StakingPoolFactory, addresses.StakingPoolFactory); + const poolCount = (await factory.stakingPoolCount()).toNumber(); + const poolIds = new Array(poolCount).fill('').map((_, i) => i + 1); + + for (const poolId of poolIds) { + const poolAddress = await cover.stakingPool(poolId); + const pool = await ethers.getContractAt(StakingPool, poolAddress); + const fee = await pool.getPoolFee(); + const rewardShareSupply = await pool.getRewardsSharesSupply(); + + const managerRewardShares = []; + const trancheRewardShares = []; + + const firstActiveTrancheId = Math.max(210, (await pool.getFirstActiveTrancheId()).toNumber()); + const activeTrancheCount = currentTrancheId - firstActiveTrancheId + 1 + 8; + const activeTrancheIds = new Array(activeTrancheCount).fill('').map((_, i) => firstActiveTrancheId + i); + + console.log('currentTrancheId:', currentTrancheId); + console.log('firstActiveTrancheId:', firstActiveTrancheId); + console.log('activeTrancheCount: ', activeTrancheCount); + console.log('activeTrancheIds: ', activeTrancheIds); + + for (const activeTrancheId of activeTrancheIds) { + const feeDeposit = await pool.getDeposit(0, activeTrancheId); + managerRewardShares.push(feeDeposit.rewardsShares); + + const { rewardsShares } = await pool.getTranche(activeTrancheId); + trancheRewardShares.push(rewardsShares); + } + + const poolManagerRewardShares = sum(managerRewardShares); + const poolTrancheRewardShares = sum(trancheRewardShares); + + console.log(`\nPool: ${poolId}`); + console.log(`Manager Reward Shares: ${formatEther(poolManagerRewardShares)}`); + console.log(`Tranche Reward Shares: ${formatEther(poolTrancheRewardShares)}`); + console.log(`Reward Share Supply : ${formatEther(rewardShareSupply)}`); + + const actualFee = poolTrancheRewardShares.isZero() + ? ethers.constants.Zero + : poolManagerRewardShares.mul(10000).div(poolTrancheRewardShares); + + console.log(`Actual Fee : ${formatUnits(actualFee, 2)}%`); + console.log(`Expected Fee: ${formatUnits(fee.mul(100), 2)}%`); + } +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/staking-nfts.js b/scripts/staking-nfts.js new file mode 100644 index 0000000000..56d56eff33 --- /dev/null +++ b/scripts/staking-nfts.js @@ -0,0 +1,33 @@ +const { ethers } = require('hardhat'); +const { addresses, StakingNFT, StakingPoolFactory } = require('@nexusmutual/deployments'); + +async function main() { + const stakingNFT = await ethers.getContractAt(StakingNFT, addresses.StakingNFT); + const factory = await ethers.getContractAt(StakingPoolFactory, addresses.StakingPoolFactory); + + const poolCount = (await factory.stakingPoolCount()).toNumber(); + const poolIds = new Array(poolCount).fill('').map((_, i) => i + 1); + const poolTokens = poolIds.reduce((acc, id) => ({ ...acc, [id]: [] }), {}); + + const tokenCount = (await stakingNFT.totalSupply()).toNumber(); + const tokenIds = new Array(tokenCount).fill('').map((_, i) => i + 1); + + for (const tokenId of tokenIds) { + process.stdout.write('.'); + const poolId = (await stakingNFT.stakingPoolOf(tokenId)).toNumber(); + poolTokens[poolId].push(tokenId); + } + + console.log('\nPool Tokens:'); + + for (const poolId of poolIds) { + console.log(`${poolId}: [${poolTokens[poolId].join(', ')}]`); + } +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/staking-search.js b/scripts/staking-search.js new file mode 100644 index 0000000000..73600b07d4 --- /dev/null +++ b/scripts/staking-search.js @@ -0,0 +1,128 @@ +const { ethers } = require('hardhat'); +const { addresses, Cover, StakingPool } = require('@nexusmutual/deployments'); + +const { formatEther, formatUnits } = ethers.utils; +const TRANCHE_DURATION = 91 * 24 * 3600; // 91 days +const sum = arr => arr.reduce((a, b) => a.add(b), ethers.constants.Zero); + +async function main() { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const currentTrancheId = Math.floor(now / TRANCHE_DURATION); + + const cover = await ethers.getContractAt(Cover, addresses.Cover); + const poolId = 22; + + const poolAddress = await cover.stakingPool(poolId); + const pool = await ethers.getContractAt(StakingPool, poolAddress); + console.log(`\nPool: ${poolId}`); + + const caches = {}; + + const fetchDataAtBlock = async blockTag => { + if (caches[blockTag]) { + return caches[blockTag]; + } + + console.log(`Fetching data at block ${blockTag}`); + const managerRewardShares = []; + const trancheRewardShares = []; + + const fee = await pool.getPoolFee({ blockTag }); + const rewardShareSupply = await pool.getRewardsSharesSupply({ blockTag }); + + const firstActiveTrancheId = (await pool.getFirstActiveTrancheId({ blockTag })).toNumber(); + const activeTrancheCount = currentTrancheId - firstActiveTrancheId + 1; + const activeTrancheIds = new Array(activeTrancheCount).fill('').map((_, i) => firstActiveTrancheId + i); + + for (const activeTrancheId of activeTrancheIds) { + const feeDeposit = await pool.getDeposit(0, activeTrancheId, { blockTag }); + managerRewardShares.push(feeDeposit.rewardsShares); + + const { rewardsShares } = await pool.getTranche(activeTrancheId, { blockTag }); + trancheRewardShares.push(rewardsShares); + } + + const poolManagerRewardShares = sum(managerRewardShares); + const poolTrancheRewardShares = sum(trancheRewardShares); + + const actualFee = poolTrancheRewardShares.isZero() + ? ethers.constants.Zero + : poolManagerRewardShares.mul(10000).div(poolTrancheRewardShares); + + return (caches[blockTag] = { + poolManagerRewardShares, + poolTrancheRewardShares, + rewardShareSupply, + expectedFee: fee.mul(100), + actualFee, + blockTag, + }); + }; + + const printData = async data => { + const { timestamp } = await ethers.provider.getBlock(data.blockTag); + console.log(`\nBlock: ${data.blockTag} (${new Date(timestamp * 1000).toISOString()})`); + + console.log(`Manager Reward Shares: ${formatEther(data.poolManagerRewardShares)}`); + console.log(`Tranche Reward Shares: ${formatEther(data.poolTrancheRewardShares)}`); + console.log(`Reward Share Supply : ${formatEther(data.rewardShareSupply)}`); + + console.log(`Actual Fee : ${formatUnits(data.actualFee, 2)}%`); + console.log(`Expected Fee: ${formatUnits(data.expectedFee, 2)}%\n`); + }; + + const findLastGood = async (from, to) => { + // process.stdout.write(`\rSearching ${from} - ${to}`); + console.log(`Searching ${from} - ${to}`); + const fromData = await fetchDataAtBlock(from); + const toData = await fetchDataAtBlock(to); + + if (fromData.actualFee.eq(toData.actualFee)) { + return { initial: fromData, final: toData }; + } + + const mid = Math.floor((from + to) / 2); + + if (mid === from) { + return { initial: fromData, final: fromData }; + } + + const midData = await fetchDataAtBlock(mid); + + return midData.actualFee.eq(fromData.actualFee) ? findLastGood(mid, to) : findLastGood(from, mid); + }; + + const blocks = [19533134, 19533135, 19533136, 19533137]; + const data = await Promise.all(blocks.map(block => fetchDataAtBlock(block))); + + for (const d of data) { + await printData(d); + } + + process.exit(0); + + const startBlock = 19145833; + const { number: endBlock } = await ethers.provider.getBlock('latest'); + // const startBlock = 19495597; + // const endBlock = 19612185; + let last = startBlock - 1; + + while (last <= endBlock) { + const { initial, final } = await findLastGood(last + 1, endBlock); + await printData(initial); + + if (last !== final.blockTag) { + last = final.blockTag; + continue; + } + + await printData(final); + } +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/staking-set-initial-pools-metadata.js b/scripts/staking-set-initial-pools-metadata.js new file mode 100644 index 0000000000..42279d8d71 --- /dev/null +++ b/scripts/staking-set-initial-pools-metadata.js @@ -0,0 +1,40 @@ +const { ethers } = require('hardhat'); +const { addresses, StakingPool, StakingPoolFactory } = require('@nexusmutual/deployments'); + +async function main() { + const factory = await ethers.getContractAt(StakingPoolFactory, addresses.StakingPoolFactory); + const stakingPoolCount = (await factory.stakingPoolCount()).toNumber(); + const stakingPoolIds = new Array(stakingPoolCount).fill('').map((_, i) => i + 1); + const cover = await ethers.getContractAt('Cover', addresses.Cover); + + const ipfsHashes = []; + + for (const poolId of stakingPoolIds) { + const stakingPoolAddress = await cover.stakingPool(poolId); + const stakingPool = await ethers.getContractAt(StakingPool, stakingPoolAddress); + + const filter = stakingPool.filters.PoolDescriptionSet(); + const events = await stakingPool.queryFilter(filter, 0, 'latest'); + + const hash = events.length > 0 ? events[events.length - 1].args.ipfsDescriptionHash : ''; + console.log(`Pool ${poolId}: ${hash}`); + + ipfsHashes.push(hash); + } + + const encodedData = ethers.utils.defaultAbiCoder.encode(['string[]'], [ipfsHashes]); + const functionSignature = ethers.utils.id('setInitialMetadata(string[])').slice(0, 10); + const data = functionSignature + encodedData.slice(2); + + console.log('Tx details:'); + console.log('from: [any ab member]'); + console.log('to:', addresses.StakingProducts); + console.log('msg.data', data); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/staking-update.js b/scripts/staking-update.js new file mode 100644 index 0000000000..1dd00c20d7 --- /dev/null +++ b/scripts/staking-update.js @@ -0,0 +1,75 @@ +require('dotenv').config(); +const { ethers } = require('hardhat'); +const { AwsKmsSigner } = require('@nexusmutual/ethers-v5-aws-kms-signer'); +const { addresses, StakingPoolFactory, StakingNFT, StakingViewer } = require('@nexusmutual/deployments'); + +const { waitForInput } = require('../lib/helpers'); + +const { AWS_REGION, AWS_KMS_KEY_ID } = process.env; +const TRANCHE_DURATION = 91 * 24 * 3600; // 91 days + +async function main() { + const viewer = await ethers.getContractAt(StakingViewer, addresses.StakingViewer); + const stakingNFT = await ethers.getContractAt(StakingNFT, addresses.StakingNFT); + const factory = await ethers.getContractAt(StakingPoolFactory, addresses.StakingPoolFactory); + + const now = (await ethers.provider.getBlock('latest')).timestamp; + const currentTrancheId = Math.floor(now / TRANCHE_DURATION); + + const tokenCount = (await stakingNFT.totalSupply()).toNumber(); + const tokenIds = new Array(tokenCount).fill('').map((_, i) => i + 1); + + const stakingPoolCount = (await factory.stakingPoolCount()).toNumber(); + const stakingPoolIds = new Array(stakingPoolCount).fill('').map((_, i) => i + 1); + + console.log('Fetching tokens and deposits'); + const [, encodedTokensWithDeposits] = await viewer.callStatic.multicall([ + viewer.interface.encodeFunctionData('processExpirations', [stakingPoolIds]), + viewer.interface.encodeFunctionData('getTokens', [tokenIds]), + ]); + + const [tokensWithDeposits] = viewer.interface.decodeFunctionResult('getTokens', encodedTokensWithDeposits); + + // data[ pool_id ][ tranche_idx ] => [token ids] + const data = stakingPoolIds.map(() => new Array(8).fill('').map(() => [])); + + for (const tokenWithDeposits of tokensWithDeposits) { + const tokenId = tokenWithDeposits.tokenId.toNumber(); + const poolId = tokenWithDeposits.poolId.toNumber(); + const poolIdx = poolId - 1; + + for (const deposit of tokenWithDeposits.deposits) { + const trancheIdx = deposit.trancheId.toNumber() - currentTrancheId; + + if (trancheIdx < 0) { + // skip expired tranches + continue; + } + + data[poolIdx][trancheIdx].push(tokenId); + } + } + + const signer = new AwsKmsSigner(AWS_KMS_KEY_ID, AWS_REGION, ethers.provider); + const cover = await ethers.getContractAt('Cover', addresses.Cover, signer); + const txData = cover.interface.encodeFunctionData('updateStakingPoolsRewardShares', [data]); + + console.log('signer:', await signer.getAddress()); + console.log('to:', addresses.Cover); + console.log('data: ', txData); + + await waitForInput('Press enter key to continue...'); + console.log('Calling updateStakingPoolsRewardShares'); + + const tx = await cover.updateStakingPoolsRewardShares(data); + const receipt = await tx.wait(); + + console.log('Tx gas:', receipt.gasUsed.toString()); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/test/fork/basic-functionality-tests.js b/test/fork/basic-functionality-tests.js index 2ecc23d1f1..c8ee9c8ae4 100644 --- a/test/fork/basic-functionality-tests.js +++ b/test/fork/basic-functionality-tests.js @@ -260,7 +260,6 @@ describe('basic functionality tests', function () { expect(nxmOut).to.be.equal(nxmReceived); expect(after.ethBalance).to.be.equal(before.ethBalance.sub(ethIn)); // member sends ETH expect(after.ethCapital).to.be.closeTo(expectedCapital, 1); // time sensitive due to rewards and debt - expect(after.ethCapital).to.be.equal(before.ethCapital.add(ethIn).sub(ethDebt).add(awEthRewards)); expect(after.nxmSupply).to.be.equal(before.nxmSupply.add(nxmReceived)); // nxmOut is minted expect(after.nxmBalance).to.be.equal(before.nxmBalance.add(nxmOut)); // member receives NXM }); @@ -1117,6 +1116,7 @@ describe('basic functionality tests', function () { newClaimsReward.address, this.stakingPoolFactory.address, this.nxm.address, + this.stakingNFT.address, ]); // MCR - MCR.sol diff --git a/test/fork/cover-products.js b/test/fork/cover-products.js index 25d2ede0e7..92ca694bca 100644 --- a/test/fork/cover-products.js +++ b/test/fork/cover-products.js @@ -58,7 +58,6 @@ describe('coverProducts', function () { this.stakingNFT = await ethers.getContractAt(abis.StakingNFT, addresses.StakingNFT); this.stakingProducts = await ethers.getContractAt(abis.StakingProducts, addresses.StakingProducts); this.swapOperator = await ethers.getContractAt(abis.SwapOperator, addresses.SwapOperator); - this.stakingPool = await ethers.getContractAt(abis.StakingPool, V2Addresses.StakingPoolImpl); this.priceFeedOracle = await ethers.getContractAt(abis.PriceFeedOracle, addresses.PriceFeedOracle); this.tokenController = await ethers.getContractAt(abis.TokenController, addresses.TokenController); this.individualClaims = await ethers.getContractAt(abis.IndividualClaims, addresses.IndividualClaims); diff --git a/test/fork/execute-governance-proposal.js b/test/fork/execute-governance-proposal.js new file mode 100644 index 0000000000..cf9ae7be64 --- /dev/null +++ b/test/fork/execute-governance-proposal.js @@ -0,0 +1,72 @@ +const assert = require('assert'); +const { abis, addresses } = require('@nexusmutual/deployments'); +const { ethers, network } = require('hardhat'); + +const { getSigner } = require('./utils'); +const { ContractCode } = require('../../lib/constants'); +const evm = require('./evm')(); + +const { parseEther, toUtf8Bytes } = ethers.utils; +const PROPOSAL_ID = null; + +if (!PROPOSAL_ID) { + throw new Error(`Please set PROPOSAL_ID to a valid proposal id. Current value: ${PROPOSAL_ID}`); +} + +describe('Execute governance proposal', function () { + async function getContractByContractCode(contractName, contractCode) { + this.master = this.master ?? (await ethers.getContractAt('NXMaster', addresses.NXMaster)); + const contractAddress = await this.master?.getLatestAddress(toUtf8Bytes(contractCode)); + return ethers.getContractAt(contractName, contractAddress); + } + + before(async function () { + // Initialize evm helper + await evm.connect(ethers.provider); + + // Get or revert snapshot if network is tenderly + if (network.name === 'tenderly') { + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + await evm.revert(TENDERLY_SNAPSHOT_ID); + console.info(`Reverted to snapshot ${TENDERLY_SNAPSHOT_ID}`); + } else { + console.info('Snapshot ID: ', await evm.snapshot()); + } + } + const [deployer] = await ethers.getSigners(); + await evm.setBalance(deployer.address, parseEther('1000')); + }); + + it('Impersonate AB members', async function () { + this.memberRoles = await getContractByContractCode(abis.MemberRoles, ContractCode.MemberRoles); + + const { memberArray: abMembers } = await this.memberRoles.members(1); + this.abMembers = []; + for (const address of abMembers) { + await evm.impersonate(address); + await evm.setBalance(address, parseEther('1000')); + this.abMembers.push(await getSigner(address)); + } + }); + + it(`execute proposal ${PROPOSAL_ID}`, async function () { + this.governance = await getContractByContractCode(abis.Governance, ContractCode.Governance); + + for (let i = 0; i < this.abMembers.length; i++) { + await this.governance.connect(this.abMembers[i]).submitVote(PROPOSAL_ID, 1); + } + + const tx = await this.governance.closeProposal(PROPOSAL_ID, { gasLimit: 21e6 }); + const receipt = await tx.wait(); + + assert.equal( + receipt.events.some(x => x.event === 'ActionSuccess' && x.address === this.governance.address), + true, + 'ActionSuccess was expected', + ); + + const proposal = await this.governance.proposal(PROPOSAL_ID); + assert.equal(proposal[2].toNumber(), 3, 'Proposal Status != ACCEPTED'); + }); +}); diff --git a/test/fork/staking-pool-rewards.js b/test/fork/staking-pool-rewards.js new file mode 100644 index 0000000000..f7d74dd2fd --- /dev/null +++ b/test/fork/staking-pool-rewards.js @@ -0,0 +1,224 @@ +const { ethers, config, network } = require('hardhat'); +const { expect } = require('chai'); +const { join } = require('node:path'); + +const evm = require('./evm')(); +const { Address, EnzymeAdress, getSigner, submitGovernanceProposal } = require('./utils'); +const { ContractCode, ProposalCategory } = require('../../lib/constants'); + +const addresses = require(join(config.paths.root, 'deployments/src/addresses.json')); + +const TRANCHE_DURATION = 91 * 24 * 3600; // 91 days +const sum = arr => arr.reduce((a, b) => a.add(b), ethers.constants.Zero); + +const { formatEther, formatUnits, parseEther, toUtf8Bytes } = ethers.utils; + +describe('StakingPool rewards update', function () { + before(async function () { + // Initialize evm helper + await evm.connect(ethers.provider); + + // Get or revert snapshot if network is tenderly + if (network.name === 'tenderly') { + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + await evm.revert(TENDERLY_SNAPSHOT_ID); + console.info(`Reverted to snapshot ${TENDERLY_SNAPSHOT_ID}`); + } else { + console.info('Snapshot ID: ', await evm.snapshot()); + } + } + const [deployer] = await ethers.getSigners(); + await evm.setBalance(deployer.address, parseEther('1000')); + }); + + it('initializes contract instances', async function () { + this.mcr = await ethers.getContractAt('MCR', addresses.MCR); + this.cover = await ethers.getContractAt('Cover', addresses.Cover); + this.nxm = await ethers.getContractAt('NXMToken', addresses.NXMToken); + this.master = await ethers.getContractAt('NXMaster', addresses.NXMaster); + this.coverNFT = await ethers.getContractAt('CoverNFT', addresses.CoverNFT); + this.coverProducts = await ethers.getContractAt('CoverProducts', addresses.CoverProducts); + this.pool = await ethers.getContractAt('Pool', addresses.Pool); + this.safeTracker = await ethers.getContractAt('SafeTracker', addresses.SafeTracker); + this.assessment = await ethers.getContractAt('Assessment', addresses.Assessment); + this.stakingNFT = await ethers.getContractAt('StakingNFT', addresses.StakingNFT); + this.stakingProducts = await ethers.getContractAt('StakingProducts', addresses.StakingProducts); + this.swapOperator = await ethers.getContractAt('SwapOperator', addresses.SwapOperator); + this.priceFeedOracle = await ethers.getContractAt('PriceFeedOracle', addresses.PriceFeedOracle); + this.tokenController = await ethers.getContractAt('TokenController', addresses.TokenController); + this.individualClaims = await ethers.getContractAt('IndividualClaims', addresses.IndividualClaims); + this.quotationData = await ethers.getContractAt('LegacyQuotationData', addresses.LegacyQuotationData); + this.newClaimsReward = await ethers.getContractAt('LegacyClaimsReward', addresses.LegacyClaimsReward); + this.proposalCategory = await ethers.getContractAt('ProposalCategory', addresses.ProposalCategory); + this.stakingPoolFactory = await ethers.getContractAt('StakingPoolFactory', addresses.StakingPoolFactory); + this.pooledStaking = await ethers.getContractAt('LegacyPooledStaking', addresses.LegacyPooledStaking); + this.yieldTokenIncidents = await ethers.getContractAt('YieldTokenIncidents', addresses.YieldTokenIncidents); + this.ramm = await ethers.getContractAt('Ramm', addresses.Ramm); + this.governance = await ethers.getContractAt('Governance', addresses.Governance); + this.memberRoles = await ethers.getContractAt('MemberRoles', addresses.MemberRoles); + + // Token Mocks + this.dai = await ethers.getContractAt('ERC20Mock', Address.DAI_ADDRESS); + this.rEth = await ethers.getContractAt('ERC20Mock', Address.RETH_ADDRESS); + this.stEth = await ethers.getContractAt('ERC20Mock', Address.STETH_ADDRESS); + this.usdc = await ethers.getContractAt('ERC20Mock', Address.USDC_ADDRESS); + this.enzymeShares = await ethers.getContractAt('ERC20Mock', EnzymeAdress.ENZYMEV4_VAULT_PROXY_ADDRESS); + }); + + it('impersonates AB members', async function () { + // set provider + await evm.connect(ethers.provider); + + const { memberArray: abMembers } = await this.memberRoles.members(1); + this.abMembers = []; + + for (const address of abMembers) { + await evm.impersonate(address); + await evm.setBalance(address, parseEther('1000')); + this.abMembers.push(await getSigner(address)); + } + }); + + it('should upgrade staking pool contract', async function () { + const extras = await ethers.deployContract('StakingExtrasLib'); + await extras.deployed(); + + const newStakingPool = await ethers.deployContract( + 'StakingPool', + [ + addresses.StakingNFT, + addresses.NXMToken, + addresses.Cover, + addresses.TokenController, + addresses.NXMaster, + addresses.StakingProducts, + ], + { libraries: { StakingExtrasLib: extras.address } }, + ); + await newStakingPool.deployed(); + + const newCover = await ethers.deployContract('Cover', [ + addresses.CoverNFT, + addresses.StakingNFT, + addresses.StakingPoolFactory, + newStakingPool.address, + ]); + await newCover.deployed(); + + const codes = [toUtf8Bytes(ContractCode.Cover)]; + const contractAddresses = [newCover.address]; + + await submitGovernanceProposal( + ProposalCategory.upgradeMultipleContracts, + ethers.utils.defaultAbiCoder.encode(['bytes2[]', 'address[]'], [codes, contractAddresses]), + this.abMembers, + this.governance, + ); + }); + + it('should update rewards shares', async function () { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const currentTrancheId = Math.floor(now / TRANCHE_DURATION); + + const tokenCount = (await this.stakingNFT.totalSupply()).toNumber(); + const tokenIds = new Array(tokenCount).fill('').map((_, i) => i + 1); + + const stakingPoolCount = (await this.stakingPoolFactory.stakingPoolCount()).toNumber(); + const stakingPoolIds = new Array(stakingPoolCount).fill('').map((_, i) => i + 1); + + console.log('Fetching tokens and deposits'); + const viewer = await ethers.getContractAt('StakingViewer', addresses.StakingViewer); + const [, encodedTokensWithDeposits] = await viewer.callStatic.multicall([ + viewer.interface.encodeFunctionData('processExpirations', [stakingPoolIds]), + viewer.interface.encodeFunctionData('getTokens', [tokenIds]), + ]); + + const [tokensWithDeposits] = viewer.interface.decodeFunctionResult('getTokens', encodedTokensWithDeposits); + + // data[ pool_id ][ tranche_idx ] => [token ids] + const data = stakingPoolIds.map(() => new Array(8).fill('').map(() => [])); + + for (const tokenWithDeposits of tokensWithDeposits) { + const tokenId = tokenWithDeposits.tokenId.toNumber(); + const poolId = tokenWithDeposits.poolId.toNumber(); + const poolIdx = poolId - 1; + + for (const deposit of tokenWithDeposits.deposits) { + const trancheIdx = deposit.trancheId.toNumber() - currentTrancheId; + + if (trancheIdx < 0) { + // skip expired tranches + continue; + } + + data[poolIdx][trancheIdx].push(tokenId); + } + } + + // const txData = this.cover.interface.encodeFunctionData('updateStakingPoolsRewardShares', [data]); + // console.log('to:', addresses.Cover); + // console.log('data: ', txData); + + await evm.impersonate(Address.SWAP_CONTROLLER); + const swapController = await getSigner(Address.SWAP_CONTROLLER); + + const tx = await this.cover.connect(swapController).updateStakingPoolsRewardShares(data); + const receipt = await tx.wait(); + + console.log('Tx gas:', receipt.gasUsed.toString()); + }); + + it('should check staking pool rewards shares', async function () { + const now = (await ethers.provider.getBlock('latest')).timestamp; + const currentTrancheId = Math.floor(now / TRANCHE_DURATION); + + const poolCount = (await this.stakingPoolFactory.stakingPoolCount()).toNumber(); + const poolIds = new Array(poolCount).fill('').map((_, i) => i + 1); + + for (const poolId of poolIds) { + const poolAddress = await this.cover.stakingPool(poolId); + const stakingPool = await ethers.getContractAt('StakingPool', poolAddress); + const fee = await stakingPool.getPoolFee(); + const rewardShareSupply = await stakingPool.getRewardsSharesSupply(); + + const managerRewardShares = []; + const trancheRewardShares = []; + + const activeTrancheIds = new Array(8).fill('').map((_, i) => currentTrancheId + i); + + for (const activeTrancheId of activeTrancheIds) { + const feeDeposit = await stakingPool.getDeposit(0, activeTrancheId); + managerRewardShares.push(feeDeposit.rewardsShares); + + const { rewardsShares } = await stakingPool.getTranche(activeTrancheId); + trancheRewardShares.push(rewardsShares); + } + + const poolManagerRewardShares = sum(managerRewardShares); + const poolTrancheRewardShares = sum(trancheRewardShares); + + console.log(`\nPool: ${poolId}`); + console.log(`Manager Reward Shares: ${formatEther(poolManagerRewardShares)}`); + console.log(`Tranche Reward Shares: ${formatEther(poolTrancheRewardShares)}`); + console.log(`Reward Share Supply : ${formatEther(rewardShareSupply)}`); + expect(poolTrancheRewardShares).to.be.eq(rewardShareSupply); + + console.log(`Manager Reward Shares in tranches: ${managerRewardShares.map(formatEther).join(', ')}`); + console.log(`Tranche Reward Shares in tranches: ${trancheRewardShares.map(formatEther).join(', ')}`); + + const actualFee = poolTrancheRewardShares.isZero() + ? ethers.constants.Zero + : poolManagerRewardShares.mul(10000).div(poolTrancheRewardShares); + + console.log(`Expected Fee: ${formatUnits(fee.mul(100), 2)}%`); + console.log(`Actual Fee : ${formatUnits(actualFee, 2)}%`); + + if (!poolTrancheRewardShares.isZero()) { + expect(actualFee).to.be.within(fee.mul(100).sub(1), fee.mul(100)); + } + } + }); + + require('./basic-functionality-tests'); +}); diff --git a/test/fork/utils.js b/test/fork/utils.js index 11f9d3f7c5..e0aeae6cd8 100644 --- a/test/fork/utils.js +++ b/test/fork/utils.js @@ -102,21 +102,19 @@ async function submitGovernanceProposal(categoryId, actionData, signers, gv) { await gv.connect(signers[0]).categorizeProposal(id, categoryId, 0); await gv.connect(signers[0]).submitProposalWithSolution(id, '', actionData); - for (let i = 0; i < signers.length; i++) { - await gv.connect(signers[i]).submitVote(id, 1); - } + await Promise.all(signers.map(signer => gv.connect(signer).submitVote(id, 1))); const tx = await gv.closeProposal(id, { gasLimit: 21e6 }); const receipt = await tx.wait(); - assert.equal( + assert( receipt.events.some(x => x.event === 'ActionSuccess' && x.address === gv.address), true, 'ActionSuccess was expected', ); const proposal = await gv.proposal(id); - assert.equal(proposal[2].toNumber(), 3, 'Proposal Status != ACCEPTED'); + assert(proposal[2].toNumber(), 3, 'Proposal Status != ACCEPTED'); } async function submitMemberVoteGovernanceProposal(categoryId, actionData, signers, gv) { @@ -248,8 +246,6 @@ async function getConfig() { } const config = { - REWARD_BONUS_PER_TRANCHE_RATIO: stakingPool.REWARD_BONUS_PER_TRANCHE_RATIO(), - REWARD_BONUS_PER_TRANCHE_DENOMINATOR: stakingPool.REWARD_BONUS_PER_TRANCHE_DENOMINATOR(), PRICE_CHANGE_PER_DAY: stakingProducts.PRICE_CHANGE_PER_DAY(), PRICE_BUMP_RATIO: stakingProducts.PRICE_BUMP_RATIO(), SURGE_PRICE_RATIO: stakingProducts.SURGE_PRICE_RATIO(), diff --git a/test/fork/withdraw-nxm.js b/test/fork/withdraw-nxm.js new file mode 100644 index 0000000000..76681de3ff --- /dev/null +++ b/test/fork/withdraw-nxm.js @@ -0,0 +1,560 @@ +const { Sema } = require('async-sema'); +const { ethers, network } = require('hardhat'); +const { expect } = require('chai'); +const { abis, addresses } = require('@nexusmutual/deployments'); + +const { + Address, + EnzymeAdress, + formatInternalContracts, + getSigner, + submitGovernanceProposal, + V2Addresses, +} = require('./utils'); +const { ContractCode, ProposalCategory: PROPOSAL_CATEGORIES, Role } = require('../../lib/constants'); +const evm = require('./evm')(); + +const { defaultAbiCoder, parseEther, toUtf8Bytes } = ethers.utils; + +describe('withdrawNXM', function () { + async function getContractByContractCode(contractName, contractCode) { + this.master = this.master ?? (await ethers.getContractAt('NXMaster', addresses.NXMaster)); + const contractAddress = await this.master?.getLatestAddress(toUtf8Bytes(contractCode)); + return ethers.getContractAt(contractName, contractAddress); + } + + before(async function () { + // Initialize evm helper + await evm.connect(ethers.provider); + + // Get or revert snapshot if network is tenderly + if (network.name === 'tenderly') { + const { TENDERLY_SNAPSHOT_ID } = process.env; + if (TENDERLY_SNAPSHOT_ID) { + await evm.revert(TENDERLY_SNAPSHOT_ID); + console.info(`Reverted to snapshot ${TENDERLY_SNAPSHOT_ID}`); + } else { + console.info('Snapshot ID: ', await evm.snapshot()); + } + } + const [deployer] = await ethers.getSigners(); + await evm.setBalance(deployer.address, parseEther('1000')); + }); + + it('load contracts', async function () { + this.mcr = await ethers.getContractAt(abis.MCR, addresses.MCR); + this.cover = await ethers.getContractAt(abis.Cover, addresses.Cover); + this.nxm = await ethers.getContractAt(abis.NXMToken, addresses.NXMToken); + this.master = await ethers.getContractAt(abis.NXMaster, addresses.NXMaster); + this.coverNFT = await ethers.getContractAt(abis.CoverNFT, addresses.CoverNFT); + this.pool = await ethers.getContractAt(abis.Pool, addresses.Pool); + this.safeTracker = await ethers.getContractAt(abis.SafeTracker, addresses.SafeTracker); + this.stakingNFT = await ethers.getContractAt(abis.StakingNFT, addresses.StakingNFT); + this.stakingProducts = await ethers.getContractAt(abis.StakingProducts, addresses.StakingProducts); + this.swapOperator = await ethers.getContractAt(abis.SwapOperator, addresses.SwapOperator); + this.stakingPool = await ethers.getContractAt(abis.StakingPool, V2Addresses.StakingPoolImpl); + this.priceFeedOracle = await ethers.getContractAt(abis.PriceFeedOracle, addresses.PriceFeedOracle); + this.individualClaims = await ethers.getContractAt(abis.IndividualClaims, addresses.IndividualClaims); + this.quotationData = await ethers.getContractAt(abis.LegacyQuotationData, addresses.LegacyQuotationData); + this.newClaimsReward = await ethers.getContractAt(abis.LegacyClaimsReward, addresses.LegacyClaimsReward); + this.proposalCategory = await ethers.getContractAt(abis.ProposalCategory, addresses.ProposalCategory); + this.stakingPoolFactory = await ethers.getContractAt(abis.StakingPoolFactory, addresses.StakingPoolFactory); + this.pooledStaking = await ethers.getContractAt(abis.LegacyPooledStaking, addresses.LegacyPooledStaking); + this.yieldTokenIncidents = await ethers.getContractAt(abis.YieldTokenIncidents, addresses.YieldTokenIncidents); + this.ramm = await ethers.getContractAt(abis.Ramm, addresses.Ramm); + this.nexusViewer = await ethers.getContractAt(abis.NexusViewer, addresses.NexusViewer); + this.stakingViewer = await ethers.getContractAt(abis.StakingViewer, addresses.StakingViewer); + this.coverProducts = await ethers.getContractAt(abis.CoverProducts, addresses.CoverProducts); + this.assessment = await ethers.getContractAt(abis.Assessment, addresses.Assessment); + this.tokenController = await ethers.getContractAt(abis.TokenController, addresses.TokenController); + + this.governance = await getContractByContractCode('Governance', ContractCode.Governance); + this.memberRoles = await getContractByContractCode('MemberRoles', ContractCode.MemberRoles); + + // Token Mocks + this.dai = await ethers.getContractAt('ERC20Mock', Address.DAI_ADDRESS); + this.rEth = await ethers.getContractAt('ERC20Mock', Address.RETH_ADDRESS); + this.stEth = await ethers.getContractAt('ERC20Mock', Address.STETH_ADDRESS); + this.usdc = await ethers.getContractAt('ERC20Mock', Address.USDC_ADDRESS); + this.enzymeShares = await ethers.getContractAt('ERC20Mock', EnzymeAdress.ENZYMEV4_VAULT_PROXY_ADDRESS); + }); + + it('Impersonate AB members', async function () { + const { memberArray: abMembers } = await this.memberRoles.members(1); + this.abMembers = []; + for (const address of abMembers) { + await evm.impersonate(address); + await evm.setBalance(address, parseEther('1000')); + this.abMembers.push(await getSigner(address)); + } + }); + + it('Collect storage data before upgrade', async function () { + this.contractData = { + assessment: { before: {}, after: {} }, + tokenController: { before: {}, after: {} }, + memberRoles: {}, // cache member addresses + }; + + const [ + assessmentCount, + membersCount, + coverCount, + stakingPoolCount, + assessmentNxm, + assessmentConfig, + tokenControllerToken, + tokenControllerQuotationData, + tokenControllerClaimsReward, + tokenControllerStakingPoolFactory, + ] = await Promise.all([ + this.assessment.getAssessmentsCount(), + this.memberRoles.membersLength(Role.Member), + this.cover.coverDataCount(), + this.stakingPoolFactory.stakingPoolCount(), + this.assessment.nxm(), + this.assessment.config(), + this.tokenController.token(), + this.tokenController.quotationData(), + this.tokenController.claimsReward(), + this.tokenController.stakingPoolFactory(), + ]); + + // Assessment + this.contractData.assessment.before.nxm = assessmentNxm; + this.contractData.assessment.before.config = assessmentConfig; + this.contractData.assessment.before.member = {}; + this.contractData.memberRoles.members = []; + this.contractData.assessment.before.assessmentCount = assessmentCount; + + const assessmentPromises = Array.from({ length: assessmentCount }, (_, id) => this.assessment.assessments(id)); + + // TokenController + this.contractData.tokenController.before.token = tokenControllerToken; + this.contractData.tokenController.before.quotationData = tokenControllerQuotationData; + this.contractData.tokenController.before.claimsReward = tokenControllerClaimsReward; + this.contractData.tokenController.before.stakingPoolFactory = tokenControllerStakingPoolFactory; + + this.contractData.tokenController.before.covers = []; + this.contractData.tokenController.before.managers = []; + this.contractData.tokenController.before.stakingPool = {}; + this.contractData.tokenController.before.managerStakingPools = {}; + this.contractData.tokenController.before.member = {}; + + const coverSemaphore = new Sema(50, { capacity: coverCount }); + const coverPromises = Array.from({ length: coverCount }).map(async (_, i) => { + await coverSemaphore.acquire(); + + process.stdout.write(`\r[BEFORE] cover ${i} of ${coverCount}`); + const coverInfo = await this.tokenController.coverInfo(i); + + coverSemaphore.release(); + + return coverInfo; + }); + + const stakingPoolPromises = Array.from({ length: stakingPoolCount }).map(async (_, i) => { + const poolId = i + 1; + const [stakingPoolNXMBalances, manager, ownershipOffer] = Promise.all([ + this.tokenController.stakingPoolNXMBalances(poolId), + this.tokenController.getStakingPoolManager(poolId), + this.tokenController.getStakingPoolOwnershipOffer(poolId), + ]); + this.contractData.tokenController.before.stakingPool[poolId] = { + stakingPoolNXMBalances, + manager, + ownershipOffer, + }; + }); + + const managerPromises = Array.from({ length: stakingPoolCount }).map(async (_, i) => { + const poolId = i + 1; + const manager = this.tokenController.getStakingPoolManager(poolId); + const stakingPools = this.tokenController.getManagerStakingPools(manager); + this.contractData.tokenController.before.managerStakingPools[manager] = stakingPools; + }); + + const [covers, assessments] = await Promise.all([ + Promise.all(coverPromises), + Promise.all(assessmentPromises), + Promise.all(managerPromises), + Promise.all(stakingPoolPromises), + ]); + + this.contractData.tokenController.before.covers = covers; + this.contractData.assessment.before.assessments = assessments; + + // Process max 6 members at a time due to tenderly rate limits (could be possibly higher in main-net) + const membersSemaphore = new Sema(6, { capacity: membersCount }); + + const processMember = async i => { + process.stdout.write(`\r[BEFORE] member ${i} of ${membersCount}`); + const [member] = await this.memberRoles.memberAtIndex(Role.Member, i); + + this.contractData.assessment.before.member[member] = { hasAlreadyVotedOn: {}, votes: [] }; + this.contractData.tokenController.before.member[member] = { tokensLocked: {} }; + + const [ + stake, + rewards, + voteCount, + lockReasons, + totalBalanceOf, + getPendingRewards, + isStakingPoolManager, + totalBalanceOfWithoutDelegations, + ] = await Promise.all([ + this.assessment.stakeOf(member), + this.assessment.getRewards(member), + this.assessment.getVoteCountOfAssessor(member), + this.tokenController.getLockReasons(member), + this.tokenController.totalBalanceOf(member), + this.tokenController.getPendingRewards(member), + this.tokenController.isStakingPoolManager(member), + this.tokenController.totalBalanceOfWithoutDelegations(member), + ]); + + const votesPromises = Array.from({ length: voteCount }, (_, i) => this.assessment.votesOf(member, i)); + const hasAlreadyVotedPromises = Array.from({ length: assessmentCount }).map(async (_, id) => { + const hasAlreadyVotedResult = this.assessment.hasAlreadyVotedOn(member, id); + this.contractData.assessment.before.member[member].hasAlreadyVotedOn[id] = hasAlreadyVotedResult; + }); + const lockReasonsPromises = lockReasons.map(async lockReason => { + const amountLocked = await this.tokenController.tokensLocked(member, lockReason); + this.contractData.tokenController.before.member[member].tokensLocked[lockReason] = amountLocked; + }); + + const [votes] = await Promise.all([ + Promise.all(votesPromises), + Promise.all(hasAlreadyVotedPromises), + Promise.all(lockReasonsPromises), + ]); + + // Set assessment data + this.contractData.assessment.before.member[member].stake = stake; + this.contractData.assessment.before.member[member].rewards = rewards; + this.contractData.assessment.before.member[member].votes = votes; + + // Set token controller data + this.contractData.tokenController.before.member[member].lockReasons = lockReasons; + this.contractData.tokenController.before.member[member].totalBalanceOf = totalBalanceOf; + this.contractData.tokenController.before.member[member].getPendingRewards = getPendingRewards; + this.contractData.tokenController.before.member[member].isStakingPoolManager = isStakingPoolManager; + this.contractData.tokenController.before.member[member].totalBalanceOfWithoutDelegations = + totalBalanceOfWithoutDelegations; + + membersSemaphore.release(); + + return member; + }; + + const memberPromises = Array.from({ length: membersCount }, (_, i) => + membersSemaphore.acquire().then(() => processMember(i)), + ); + + this.contractData.memberRoles.members = await Promise.all(memberPromises); + }); + + it('Upgrade contracts', async function () { + const contractsBeforePromise = this.master.getInternalContracts(); + + const assessmentPromise = ethers.deployContract('Assessment', [this.nxm.address]); + + const tokenControllerPromise = ethers.deployContract('TokenController', [ + this.quotationData.address, + this.newClaimsReward.address, + this.stakingPoolFactory.address, + this.nxm.address, + this.stakingNFT.address, + ]); + + const [contractsBefore, assessment, tokenController] = await Promise.all([ + contractsBeforePromise, + assessmentPromise, + tokenControllerPromise, + ]); + + const contractCodeAddressMapping = { + [ContractCode.Assessment]: assessment.address, + [ContractCode.TokenController]: tokenController.address, + }; + + // NOTE: Do not manipulate the map between Object.keys and Object.values otherwise the ordering could go wrong + const codes = Object.keys(contractCodeAddressMapping).map(code => toUtf8Bytes(code)); + const addresses = Object.values(contractCodeAddressMapping); + + await submitGovernanceProposal( + PROPOSAL_CATEGORIES.upgradeMultipleContracts, // upgradeMultipleContracts(bytes2[],address[]) + defaultAbiCoder.encode(['bytes2[]', 'address[]'], [codes, addresses]), + this.abMembers, + this.governance, + ); + + const contractsAfter = await this.master.getInternalContracts(); + + this.assessment = await getContractByContractCode('Assessment', ContractCode.Assessment); + this.tokenController = await getContractByContractCode('TokenController', ContractCode.TokenController); + + console.info('Upgrade Contracts before:', formatInternalContracts(contractsBefore)); + console.info('Upgrade Contracts after:', formatInternalContracts(contractsAfter)); + }); + + it('Compares storage of upgraded Assessment contracts', async function () { + const [assessmentCount, nxm, config] = await Promise.all([ + this.assessment.getAssessmentsCount(), + this.assessment.nxm(), + this.assessment.config(), + ]); + + const assessmentBefore = this.contractData.assessment.before; + expect(assessmentCount).to.equal(assessmentBefore.assessmentCount); + expect(nxm).to.equal(assessmentBefore.nxm); + expect(config).to.deep.equal(assessmentBefore.config); + + this.contractData.assessment.after.member = {}; + + const assessmentPromises = Array.from({ length: assessmentCount }, (_, id) => this.assessment.assessments(id)); + const assessments = await Promise.all(assessmentPromises); + expect(assessments).to.deep.equal(this.contractData.assessment.before.assessments); + }); + + it('Compares storage of upgraded TokenController contract', async function () { + const [coverCount, stakingPoolCount, token, quotationData, claimsReward, stakingPoolFactory, stakingNFT] = + await Promise.all([ + this.cover.coverDataCount(), + this.stakingPoolFactory.stakingPoolCount(), + this.tokenController.token(), + this.tokenController.quotationData(), + this.tokenController.claimsReward(), + this.tokenController.stakingPoolFactory(), + this.tokenController.stakingNFT(), + ]); + + // TokenController + const tokenControllerBefore = this.contractData.tokenController.before; + expect(token).to.equal(tokenControllerBefore.token); + expect(quotationData).to.equal(tokenControllerBefore.quotationData); + expect(claimsReward).to.equal(tokenControllerBefore.claimsReward); + expect(stakingPoolFactory).to.equal(tokenControllerBefore.stakingPoolFactory); + expect(stakingNFT).to.equal(this.stakingNFT.address); + + this.contractData.tokenController.after.covers = []; + this.contractData.tokenController.after.managers = []; + this.contractData.tokenController.after.stakingPool = {}; + this.contractData.tokenController.after.managerStakingPools = {}; + + const coverSemaphore = new Sema(50, { capacity: coverCount }); + const coverPromises = Array.from({ length: coverCount }).map(async (_, i) => { + await coverSemaphore.acquire(); + + process.stdout.write(`\r[AFTER] cover ${i} of ${coverCount}`); + const coverInfo = await this.tokenController.coverInfo(i); + + coverSemaphore.release(); + + return coverInfo; + }); + + const stakingPoolPromises = Array.from({ length: stakingPoolCount }).map(async (_, i) => { + const poolId = i + 1; + const [stakingPoolNXMBalances, manager, ownershipOffer] = await Promise.all([ + this.tokenController.stakingPoolNXMBalances(poolId), + this.tokenController.getStakingPoolManager(poolId), + this.tokenController.getStakingPoolOwnershipOffer(poolId), + ]); + expect(stakingPoolNXMBalances).to.deep.equal(tokenControllerBefore.stakingPool[poolId].stakingPoolNXMBalances); + expect(manager).to.deep.equal(tokenControllerBefore.stakingPool[poolId].manager); + expect(ownershipOffer).to.deep.equal(tokenControllerBefore.stakingPool[poolId].ownershipOffer); + }); + + const managerPromises = Array.from({ length: stakingPoolCount }).map(async (_, i) => { + const poolId = i + 1; + const manager = this.tokenController.getStakingPoolManager(poolId); + const stakingPools = await this.tokenController.getManagerStakingPools(manager); + expect(stakingPools).deep.equal(tokenControllerBefore.managerStakingPools[manager]); + }); + + const [covers] = await Promise.all([ + Promise.all(coverPromises), + Promise.all(managerPromises), + Promise.all(stakingPoolPromises), + ]); + expect(covers).to.deep.equal(tokenControllerBefore.covers); + }); + + it('Compares member storage of upgraded Assessment / TokenController contracts', async function () { + const membersCount = this.contractData.memberRoles.members.length; + const assessmentCount = this.contractData.assessment.before.assessmentCount; + + // Process max 6 members at a time due to tenderly rate limits (could be possibly higher in main-net) + const membersSemaphore = new Sema(6, { capacity: membersCount }); + + const processMember = async (member, i) => { + process.stdout.write(`\r[AFTER] member ${i} of ${membersCount}`); + + const assessmentMemberBefore = this.contractData.assessment.before.member[member]; + const tokenControllerMemberBefore = this.contractData.tokenController.before.member[member]; + + const [ + stake, + rewards, + voteCount, + lockReasons, + totalBalanceOf, + getPendingRewards, + isStakingPoolManager, + totalBalanceOfWithoutDelegations, + ] = await Promise.all([ + this.assessment.stakeOf(member), + this.assessment.getRewards(member), + this.assessment.getVoteCountOfAssessor(member), + this.tokenController.getLockReasons(member), + this.tokenController.totalBalanceOf(member), + this.tokenController.getPendingRewards(member), + this.tokenController.isStakingPoolManager(member), + this.tokenController.totalBalanceOfWithoutDelegations(member), + ]); + + const votesPromises = Array.from({ length: voteCount }, (_, i) => this.assessment.votesOf(member, i)); + const hasAlreadyVotedPromises = Array.from({ length: assessmentCount }).map(async (_, id) => { + const hasAlreadyVotedResult = await this.assessment.hasAlreadyVotedOn(member, id); + expect(hasAlreadyVotedResult).to.equal(assessmentMemberBefore.hasAlreadyVotedOn[id]); + }); + const lockReasonsPromises = lockReasons.map(async lockReason => { + const amountLocked = await this.tokenController.tokensLocked(member, lockReason); + expect(amountLocked).to.equal(tokenControllerMemberBefore.tokensLocked[lockReason]); + }); + + const [votes] = await Promise.all([ + Promise.all(votesPromises), + Promise.all(hasAlreadyVotedPromises), + Promise.all(lockReasonsPromises), + ]); + + // Assessment Member data + expect(stake).to.deep.equal(assessmentMemberBefore.stake); + expect(rewards).to.deep.equal(assessmentMemberBefore.rewards); + expect(votes).to.deep.equal(assessmentMemberBefore.votes); + + // TokenController Member data + expect(lockReasons).to.deep.equal(tokenControllerMemberBefore.lockReasons); + expect(totalBalanceOf).to.deep.equal(tokenControllerMemberBefore.totalBalanceOf); + expect(getPendingRewards).to.deep.equal(tokenControllerMemberBefore.getPendingRewards); + expect(isStakingPoolManager).to.deep.equal(tokenControllerMemberBefore.isStakingPoolManager); + expect(totalBalanceOfWithoutDelegations).to.deep.equal( + tokenControllerMemberBefore.totalBalanceOfWithoutDelegations, + ); + + membersSemaphore.release(); + }; + + const memberPromises = this.contractCode.memberRoles.members.map((member, i) => + membersSemaphore.acquire().then(() => processMember(member, i)), + ); + + await Promise.all(memberPromises); + }); + + it('should withdraw all claimable NXM', async function () { + this.HUGH = '0x87B2a7559d85f4653f13E6546A14189cd5455d45'; + + await evm.impersonate(this.HUGH); + await evm.setBalance(this.HUGH, parseEther('1000')); + + this.HUGH_SIGNER = await getSigner(this.HUGH); + this.stakingTokenIds = [2, 31, 38, 86]; + + // expire current stake to make it claimable + await evm.increaseTime(24 * 60 * 60 * 365); + await evm.mine(); + await this.stakingViewer.processExpirationsFor(this.stakingTokenIds); + + const tokensPromises = this.stakingTokenIds.map(tokenId => this.stakingViewer.getToken(tokenId)); + + const [balanceBefore, claimableNXMBefore, managerTokens, tokens] = await Promise.all([ + this.nxm.balanceOf(this.HUGH), + this.nexusViewer.getClaimableNXM(this.HUGH, this.stakingTokenIds), + this.stakingViewer.getManagerTokenRewardsByAddr(this.HUGH), + Promise.all(tokensPromises), + ]); + + const { assessmentStake, assessmentRewards } = claimableNXMBefore; + + // withdrawNXM params + const withdrawAssessment = { stake: assessmentStake.gt(0), rewards: assessmentRewards.gt(0) }; + let stakingPoolManagerRewards = []; + let stakingPoolDeposits = []; + const assessmentRewardsBatchSize = 0; // withdraw all + const govRewardsBatchSize = 100; + + // only withdraw if staking pool stake and rewards are BOTH claimable + if (claimableNXMBefore.stakingPoolTotalExpiredStake.gt(0) && claimableNXMBefore.stakingPoolTotalRewards.gt(0)) { + stakingPoolDeposits = tokens.map(t => ({ tokenId: t.tokenId, trancheIds: t.deposits.map(d => d.trancheId) })); + } + + // only withdraw manager rewards if available + if (claimableNXMBefore.managerTotalRewards.gt(0) && managerTokens.length) { + stakingPoolManagerRewards = managerTokens.map(t => ({ + poolId: t.poolId, + trancheIds: t.deposits.map(d => d.trancheId), + })); + } + + await this.tokenController + .connect(this.HUGH_SIGNER) + .withdrawNXM( + withdrawAssessment, + stakingPoolDeposits, + stakingPoolManagerRewards, + assessmentRewardsBatchSize, + govRewardsBatchSize, + ); + + const claimableNXMAfter = await this.nexusViewer.getClaimableNXM(this.HUGH, this.stakingTokenIds); + + const balanceAfter = await this.nxm.balanceOf(this.HUGH); + + // gov rewards are always withdrawn + let expectedBalance = balanceBefore.add(claimableNXMBefore.governanceRewards); + + // assessment stake + if (withdrawAssessment.stake) { + expect(claimableNXMAfter.assessmentStake).to.equal(0); + expectedBalance = expectedBalance.add(claimableNXMBefore.assessmentStake); + } else { + expect(claimableNXMAfter.assessmentStake).to.equal(claimableNXMBefore.assessmentStake); + } + + // assessment rewards + if (withdrawAssessment.rewards) { + expect(claimableNXMAfter.assessmentRewards).to.equal(0); + expectedBalance = expectedBalance.add(claimableNXMBefore.assessmentRewards); + } else { + expect(claimableNXMAfter.assessmentStake).to.equal(claimableNXMBefore.assessmentRewards); + } + + // staking pool stake / rewards + if (stakingPoolDeposits.length) { + expect(claimableNXMAfter.stakingPoolTotalExpiredStake).to.equal(0); + expect(claimableNXMAfter.stakingPoolTotalRewards).to.equal(0); + expectedBalance = expectedBalance + .add(claimableNXMBefore.stakingPoolTotalExpiredStake) + .add(claimableNXMBefore.stakingPoolTotalRewards); + } else { + expect(claimableNXMAfter.stakingPoolTotalExpiredStake).to.equal(claimableNXMBefore.stakingPoolTotalExpiredStake); + expect(claimableNXMAfter.stakingPoolTotalRewards).to.equal(claimableNXMBefore.stakingPoolTotalRewards); + } + + // staking pool manager rewards + if (stakingPoolManagerRewards.length) { + expect(claimableNXMAfter.managerTotalRewards).to.equal(0); + expectedBalance = expectedBalance.add(claimableNXMBefore.managerTotalRewards); + } else { + expect(claimableNXMAfter.managerTotalRewards).to.equal(claimableNXMBefore.managerTotalRewards); + } + + expect(balanceAfter).to.equal(expectedBalance); + }); + + require('./basic-functionality-tests'); +}); diff --git a/test/integration/Cover/totalActiveCover.js b/test/integration/Cover/totalActiveCover.js index c8b25a5dca..435b3b072f 100644 --- a/test/integration/Cover/totalActiveCover.js +++ b/test/integration/Cover/totalActiveCover.js @@ -133,7 +133,7 @@ describe('totalActiveCover', function () { expect(await stakingProducts.stakingPool(1)).to.be.equal(stakingPool1.address); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); + await stake({ contracts: fixture.contracts, stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); // cover buyer gets yield token await transferYieldToken({ tokenOwner: fixture.accounts.defaultSender, coverBuyer1, cg, ybETH }); @@ -203,7 +203,7 @@ describe('totalActiveCover', function () { buyCoverFixture; // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); + await stake({ contracts: fixture.contracts, stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); // cover buyer gets yield token await transferYieldToken({ tokenOwner: fixture.accounts.defaultSender, coverBuyer1, cg, ybETH }); @@ -280,7 +280,7 @@ describe('totalActiveCover', function () { buyCoverFixture; // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); + await stake({ contracts: fixture.contracts, stakingPool: stakingPool1, staker: staker1, period, gracePeriod }); // cover buyer gets yield token await transferYieldToken({ tokenOwner: fixture.accounts.defaultSender, coverBuyer1, cg, ybETH }); diff --git a/test/integration/IndividualClaims/submitClaim.js b/test/integration/IndividualClaims/submitClaim.js index b9035e4102..bd17365ae7 100644 --- a/test/integration/IndividualClaims/submitClaim.js +++ b/test/integration/IndividualClaims/submitClaim.js @@ -55,7 +55,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -107,7 +114,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -160,7 +174,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -210,7 +231,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -260,7 +288,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -319,7 +354,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -378,7 +420,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -436,7 +485,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -496,7 +552,14 @@ describe('submitClaim', function () { const amount = parseUnits('10', usdcDecimals); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -557,7 +620,14 @@ describe('submitClaim', function () { const amount = parseUnits('10', usdcDecimals); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -618,7 +688,14 @@ describe('submitClaim', function () { const amount = parseUnits('10', usdcDecimals); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -676,7 +753,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -797,7 +881,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -924,7 +1015,14 @@ describe('submitClaim', function () { const amount = parseUnits('10', usdcDecimals); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -1049,7 +1147,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover await buyCover({ @@ -1169,7 +1274,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); await stakeOnly({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, trancheIdOffset: 1 }); // Buy Cover @@ -1287,7 +1399,14 @@ describe('submitClaim', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); await stakeOnly({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, trancheIdOffset: 1 }); const allocationId = await stakingPool1.getNextAllocationId(); diff --git a/test/integration/MCR/getMCR.js b/test/integration/MCR/getMCR.js index 9235112c43..f53e644868 100644 --- a/test/integration/MCR/getMCR.js +++ b/test/integration/MCR/getMCR.js @@ -52,6 +52,7 @@ async function getMCRSetup() { await tk.connect(await ethers.getImpersonatedSigner(operator)).mint(member1.address, parseEther('1000000000000')); await tk.connect(member1).approve(tc.address, MaxUint256); await stake({ + contracts: fixture.contracts, stakingPool, staker: member1, productId: ethCoverTemplate.productId, diff --git a/test/integration/MCR/getTotalActiveCoverAmount.js b/test/integration/MCR/getTotalActiveCoverAmount.js index 129db51306..6953796250 100644 --- a/test/integration/MCR/getTotalActiveCoverAmount.js +++ b/test/integration/MCR/getTotalActiveCoverAmount.js @@ -51,6 +51,7 @@ async function getTotalActiveCoverAmountSetup() { await tk.connect(await ethers.getImpersonatedSigner(operator)).mint(member1.address, parseEther('100000')); await tk.connect(member1).approve(tc.address, MaxUint256); await stake({ + contracts: fixture.contracts, stakingPool, staker: member1, productId: ethCoverTemplate.productId, diff --git a/test/integration/MCR/updateMCR.js b/test/integration/MCR/updateMCR.js index 9b71b49e3a..33bcae1611 100644 --- a/test/integration/MCR/updateMCR.js +++ b/test/integration/MCR/updateMCR.js @@ -46,6 +46,7 @@ async function updateMCRSetup() { await tk.connect(await ethers.getImpersonatedSigner(operator)).mint(member1.address, parseEther('1000000000000')); await tk.connect(member1).approve(tc.address, MaxUint256); await stake({ + contracts: fixture.contracts, stakingPool, staker: member1, productId: newEthCoverTemplate.productId, @@ -54,6 +55,7 @@ async function updateMCRSetup() { amount: parseEther('10000000000'), }); await stake({ + contracts: fixture.contracts, stakingPool, staker: member1, productId: 2, diff --git a/test/integration/Master/emergency-pause.js b/test/integration/Master/emergency-pause.js index eee9f923da..8e2b3cb163 100644 --- a/test/integration/Master/emergency-pause.js +++ b/test/integration/Master/emergency-pause.js @@ -57,7 +57,7 @@ describe('emergency pause', function () { it('should be able to perform proxy and replaceable upgrades during emergency pause', async function () { const fixture = await loadFixture(emergencyPauseSetup); - const { master, gv, qd, lcr, spf, tk } = fixture.contracts; + const { master, gv, qd, lcr, spf, tk, stakingNFT } = fixture.contracts; const emergencyAdmin = fixture.accounts.emergencyAdmin; const owner = fixture.accounts.defaultSender; @@ -77,6 +77,7 @@ describe('emergency pause', function () { lcr.address, spf.address, tk.address, + stakingNFT.address, ); const contractCodes = [mcrCode, tcCode]; @@ -167,7 +168,14 @@ describe('emergency pause', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover const expectedPremium = amount @@ -227,7 +235,14 @@ describe('emergency pause', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, gracePeriod, period, productId }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + gracePeriod, + period, + productId, + }); // Buy Cover const expectedPremium = amount diff --git a/test/integration/Master/master.js b/test/integration/Master/master.js index 8675809e96..7374a699d3 100644 --- a/test/integration/Master/master.js +++ b/test/integration/Master/master.js @@ -154,7 +154,7 @@ describe('master', function () { it('upgrade proxy contract', async function () { const fixture = await loadFixture(setup); - const { master, gv, qd, lcr, spf, tk } = fixture.contracts; + const { master, gv, qd, lcr, spf, tk, stakingNFT } = fixture.contracts; const code = hex('TC'); const TokenController = await ethers.getContractFactory('TokenController'); @@ -163,6 +163,7 @@ describe('master', function () { lcr.address, spf.address, tk.address, + stakingNFT.address, ); const contractCodes = [code]; @@ -184,7 +185,7 @@ describe('master', function () { it('upgrade proxies and replaceables', async function () { const fixture = await loadFixture(setup); - const { master, gv, qd, lcr, spf, tk } = fixture.contracts; + const { master, gv, qd, lcr, spf, stakingNFT, tk } = fixture.contracts; const mcrCode = hex('MC'); const tcCode = hex('TC'); @@ -197,6 +198,7 @@ describe('master', function () { lcr.address, spf.address, tk.address, + stakingNFT.address, ); const contractCodes = [mcrCode, tcCode]; @@ -239,7 +241,7 @@ describe('master', function () { it('upgrades all contracts', async function () { const fixture = await loadFixture(setup); - const { master, gv, dai, priceFeedOracle, p1, tk, qd, lcr, spf, cover, coverNFT } = fixture.contracts; + const { master, gv, dai, priceFeedOracle, p1, tk, qd, lcr, spf, stakingNFT, cover, coverNFT } = fixture.contracts; const owner = fixture.accounts.defaultSender; const TokenController = await ethers.getContractFactory('TokenController'); @@ -256,7 +258,7 @@ describe('master', function () { const contractCodes = ['TC', 'CR', 'P1', 'MC', 'GV', 'PC', 'MR', 'PS', 'CI']; const newAddresses = [ - await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address), + await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address, stakingNFT.address), await LegacyClaimsReward.deploy(master.address, dai.address), pool, await MCR.deploy(master.address, 0), @@ -303,7 +305,7 @@ describe('master', function () { it('upgrades Governance, TokenController and MemberRoles 2 times in a row', async function () { const fixture = await loadFixture(setup); - const { master, gv, qd, lcr, spf, tk } = fixture.contracts; + const { master, gv, qd, lcr, spf, stakingNFT, tk } = fixture.contracts; const TokenController = await ethers.getContractFactory('TokenController'); const MemberRoles = await ethers.getContractFactory('MemberRoles'); @@ -312,7 +314,7 @@ describe('master', function () { { const contractCodes = ['TC', 'GV', 'MR']; const newAddresses = [ - await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address), + await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address, stakingNFT.address), await Governance.deploy(), await MemberRoles.deploy(tk.address), ].map(c => c.address); @@ -332,7 +334,7 @@ describe('master', function () { { const contractCodes = ['TC', 'GV', 'MR']; const newAddresses = [ - await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address), + await TokenController.deploy(qd.address, lcr.address, spf.address, tk.address, stakingNFT.address), await Governance.deploy(), await MemberRoles.deploy(tk.address), ].map(c => c.address); diff --git a/test/integration/MemberRoles/switchMembershipAndAssets.js b/test/integration/MemberRoles/switchMembershipAndAssets.js index 048c92c466..7ab5002ec4 100644 --- a/test/integration/MemberRoles/switchMembershipAndAssets.js +++ b/test/integration/MemberRoles/switchMembershipAndAssets.js @@ -122,7 +122,7 @@ describe('switchMembershipAndAssets', function () { const amount = parseEther('1'); // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker, gracePeriod, period, productId }); + await stake({ contracts: fixture.contracts, stakingPool: stakingPool1, staker, gracePeriod, period, productId }); for (let i = 0; i < 3; i++) { const expectedPremium = parseEther('1'); diff --git a/test/integration/Pool/pool.js b/test/integration/Pool/pool.js index 97d6305239..1e69f4bd91 100644 --- a/test/integration/Pool/pool.js +++ b/test/integration/Pool/pool.js @@ -40,6 +40,7 @@ async function tokenPriceSetup() { await tk.connect(member1).approve(tc.address, MaxUint256); await stake({ + contracts: fixture.contracts, stakingPool, staker: member1, productId: ethCoverTemplate.productId, diff --git a/test/integration/StakingProducts/setProducts.js b/test/integration/StakingProducts/setProducts.js index 445498e7d8..1b04027c9b 100644 --- a/test/integration/StakingProducts/setProducts.js +++ b/test/integration/StakingProducts/setProducts.js @@ -41,15 +41,10 @@ async function setProductsSetup() { const initialPoolFee = 50; // 50% const maxPoolFee = 80; // 80% - const [poolId] = await stakingProducts.callStatic.createStakingPool(true, initialPoolFee, maxPoolFee, [], ''); - - await stakingProducts.connect(manager).createStakingPool( - true, // isPrivatePool, - initialPoolFee, - maxPoolFee, - [], - '', // ipfsDescriptionHash - ); + const params = [true /* isPrivatePool */, initialPoolFee, maxPoolFee, [], 'ipfsDescriptionHash']; + const [poolId] = await stakingProducts.callStatic.createStakingPool(...params); + + await stakingProducts.connect(manager).createStakingPool(...params); return { ...fixture, diff --git a/test/integration/TokenController/setup.js b/test/integration/TokenController/setup.js new file mode 100644 index 0000000000..a8d56c1735 --- /dev/null +++ b/test/integration/TokenController/setup.js @@ -0,0 +1,151 @@ +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { ethers } = require('hardhat'); + +const setup = require('../setup'); +const { setEtherBalance, setNextBlockTime, impersonateAccount } = require('../utils').evm; +const { calculatePremium } = require('../utils/cover'); +const { calculateFirstTrancheId } = require('../utils/staking'); +const { daysToSeconds } = require('../../../lib/helpers'); +const { getInternalPrice } = require('../../utils/rammCalculations'); + +const { parseEther } = ethers.utils; +const { AddressZero, MaxUint256 } = ethers.constants; + +const stakedProductParamTemplate = { + productId: 1, + recalculateEffectiveWeight: true, + setTargetWeight: true, + targetWeight: 100, + setTargetPrice: true, + targetPrice: 100, +}; +const buyCoverFixture = { + coverId: 0, + owner: AddressZero, + productId: stakedProductParamTemplate.productId, + coverAsset: 0b0, + amount: parseEther('1'), + period: daysToSeconds(30), + maxPremiumInAsset: MaxUint256, + paymentAsset: 0b0, + commissionRatio: 0, + commissionDestination: AddressZero, + ipfsData: 'ipfs data', +}; + +async function stakingPoolSetup(fixture) { + const { stakingPool1, stakingPool2, stakingPool3, stakingProducts, tc: tokenController, tk: nxm } = fixture.contracts; + const [manager1, manager2, manager3] = fixture.accounts.stakingPoolManagers; + + const operatorAddress = await nxm.operator(); + await impersonateAccount(operatorAddress); + const operator = await ethers.provider.getSigner(operatorAddress); + + await setEtherBalance(manager1.address, parseEther('10000')); + await setEtherBalance(operatorAddress, parseEther('10000')); + + // mint and set allowance + await nxm.connect(operator).mint(manager1.address, parseEther('10000000')); + await nxm.connect(operator).mint(manager2.address, parseEther('10000000')); + await nxm.connect(manager1).approve(tokenController.address, ethers.constants.MaxUint256); + + // set products + await stakingProducts.connect(manager1).setProducts(1, [stakedProductParamTemplate]); + await stakingProducts.connect(manager2).setProducts(2, [stakedProductParamTemplate]); + await stakingProducts.connect(manager3).setProducts(3, [stakedProductParamTemplate]); + + // stake + const stakeAmount = parseEther('900000'); + const latestBlock = await ethers.provider.getBlock('latest'); + const firstActiveTrancheId = calculateFirstTrancheId(latestBlock, buyCoverFixture.period, 0); + + const trancheId = firstActiveTrancheId + 5; + const depositParams = [stakeAmount, trancheId, 0, manager1.address]; + const tokenId1 = await stakingPool1.connect(manager1).callStatic.depositTo(...depositParams); + const tokenId2 = await stakingPool2.connect(manager1).callStatic.depositTo(...depositParams); + const tokenId3 = await stakingPool3.connect(manager1).callStatic.depositTo(...depositParams); + + await stakingPool1.connect(manager1).depositTo(...depositParams); + await stakingPool2.connect(manager1).depositTo(...depositParams); + await stakingPool3.connect(manager1).depositTo(...depositParams); + + fixture.tokenIds = [tokenId1, tokenId2, tokenId3]; + fixture.stakeAmount = stakeAmount; + fixture.trancheIds = [[trancheId], [trancheId], [trancheId]]; + fixture.trancheId = trancheId; +} + +async function generateStakeRewards(fixture) { + const { stakingProducts, tc: tokenController, p1: pool, ra: ramm, mcr, cover } = fixture.contracts; + + const [coverBuyer, coverReceiver] = fixture.accounts.members; + const { NXM_PER_ALLOCATION_UNIT } = fixture.config; + const { productId, period, amount } = buyCoverFixture; + + const { timestamp: currentTimestamp } = await ethers.provider.getBlock('latest'); + const nextBlockTimestamp = currentTimestamp + 10; + const ethRate = await getInternalPrice(ramm, pool, tokenController, mcr, nextBlockTimestamp); + + const product = await stakingProducts.getProduct(1, productId); + const { premiumInAsset: premium } = calculatePremium( + amount, + ethRate, + period, + product.bumpedPrice, + NXM_PER_ALLOCATION_UNIT, + ); + const coverAmountAllocationPerPool = amount.div(3); + + await setNextBlockTime(nextBlockTimestamp); + await cover.connect(coverBuyer).buyCover( + { ...buyCoverFixture, owner: coverReceiver.address, maxPremiumInAsset: premium }, + [ + { poolId: 1, coverAmountInAsset: coverAmountAllocationPerPool }, + { poolId: 2, coverAmountInAsset: coverAmountAllocationPerPool }, + { poolId: 3, coverAmountInAsset: coverAmountAllocationPerPool }, + ], + { value: premium }, + ); +} + +async function generateAssessmentRewards(fixture) { + const { ci: individualClaims, as: assessment } = fixture.contracts; + const [manager1, manager2] = fixture.accounts.stakingPoolManagers; + const coverReceiver = fixture.accounts.members[1]; + + // stake + await assessment.connect(manager1).stake(fixture.stakeAmount); + await assessment.connect(manager2).stake(fixture.stakeAmount); + + // claim + await individualClaims.connect(coverReceiver).submitClaim(1, 0, parseEther('1'), '', { value: parseEther('1') }); + + // vote + await assessment.connect(manager1).castVotes([0], [true], ['Assessment data hash'], 0); + await assessment.connect(manager2).castVotes([0], [true], ['Assessment data hash'], 0); +} + +async function withdrawNXMSetup() { + const fixture = await loadFixture(setup); + + // do not change the order + await stakingPoolSetup(fixture); + await generateStakeRewards(fixture); + await generateAssessmentRewards(fixture); + + // StakingPool1 deposit params + const stakingPoolDeposits = []; + const stakingPoolManagerRewards = []; + const batchSize = 0; + + return { + ...fixture, + stakingPoolDeposits, + stakingPoolManagerRewards, + batchSize, + }; +} + +module.exports = { + withdrawNXMSetup, +}; diff --git a/test/integration/TokenController/withdrawNXM.js b/test/integration/TokenController/withdrawNXM.js new file mode 100644 index 0000000000..09d2a50616 --- /dev/null +++ b/test/integration/TokenController/withdrawNXM.js @@ -0,0 +1,182 @@ +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { expect } = require('chai'); +const { ethers } = require('hardhat'); + +const { withdrawNXMSetup } = require('./setup'); +const { increaseTime, setNextBlockTime, mineNextBlock } = require('../utils').evm; + +const ONE_DAY_SECONDS = 24 * 60 * 60; +const TRANCHE_DURATION_SECONDS = 91 * ONE_DAY_SECONDS; + +const setTime = async timestamp => { + await setNextBlockTime(timestamp); + await mineNextBlock(); +}; + +describe('withdrawNXM', function () { + it('should withdraw assessment stake if withdrawAssessment.stake is true', async function () { + const fixture = await loadFixture(withdrawNXMSetup); + const { tk: nxm, as: assessment, tc: tokenController } = fixture.contracts; + const { stakingPoolDeposits, stakingPoolManagerRewards, batchSize } = fixture; + const [manager] = fixture.accounts.stakingPoolManagers; + + const balanceBefore = await nxm.balanceOf(manager.address); + const assessmentStakeBefore = await assessment.stakeOf(manager.address); + + expect(assessmentStakeBefore.amount).to.be.equal(fixture.stakeAmount); + + // adjust time so stake is no longer locked for assessment + const { timestamp } = await ethers.provider.getBlock('latest'); + const { stakeLockupPeriodInDays } = await assessment.config(); + await setTime(timestamp + stakeLockupPeriodInDays * ONE_DAY_SECONDS); + + const withdrawAssessment = { stake: true, rewards: false }; + + await tokenController + .connect(manager) + .withdrawNXM(withdrawAssessment, stakingPoolDeposits, stakingPoolManagerRewards, batchSize, batchSize); + + const balanceAfter = await nxm.balanceOf(manager.address); + const assessmentStakeAfter = await assessment.stakeOf(manager.address); + + expect(balanceAfter).to.equal(balanceBefore.add(assessmentStakeBefore.amount)); + expect(assessmentStakeAfter.amount).to.be.equal(0); + }); + + it('should withdraw assessment rewards if withdrawAssessment.rewards is true', async function () { + const fixture = await loadFixture(withdrawNXMSetup); + const { tk: nxm, as: assessment, tc: tokenController } = fixture.contracts; + const { stakingPoolDeposits, stakingPoolManagerRewards, batchSize } = fixture; + const [manager] = fixture.accounts.stakingPoolManagers; + + const balanceBefore = await nxm.balanceOf(manager.address); + + // finalize assessment to release rewards + const withdrawAssessment = { rewards: true, stake: false }; + const { timestamp } = await ethers.provider.getBlock('latest'); + const { minVotingPeriodInDays, payoutCooldownInDays } = await assessment.config(); + await setTime(timestamp + (minVotingPeriodInDays + payoutCooldownInDays) * ONE_DAY_SECONDS + 1); + + const assessmentRewardsBefore = await assessment.getRewards(manager.address); + expect(assessmentRewardsBefore.withdrawableAmountInNXM.toString()).to.not.equal('0'); + + await tokenController + .connect(manager) + .withdrawNXM(withdrawAssessment, stakingPoolDeposits, stakingPoolManagerRewards, batchSize, batchSize); + + const balanceAfter = await nxm.balanceOf(manager.address); + const assessmentRewardsAfter = await assessment.getRewards(manager.address); + + expect(balanceAfter).to.equal(balanceBefore.add(assessmentRewardsBefore.withdrawableAmountInNXM)); + expect(assessmentRewardsAfter.withdrawableAmountInNXM).to.equal('0'); + }); + + it('should withdraw staking pool stake and rewards if stakingPoolDeposits is not empty', async function () { + const fixture = await loadFixture(withdrawNXMSetup); + const { stakingPool1, stakingViewer, tk: nxm, tc: tokenController } = fixture.contracts; + const { stakingPoolManagerRewards, batchSize } = fixture; + const [manager] = fixture.accounts.stakingPoolManagers; + + const balanceBefore = await nxm.balanceOf(manager.address); + const [tokenId] = fixture.tokenIds; // StakingPool1 stake tokenId + + await increaseTime(TRANCHE_DURATION_SECONDS * 7); + await stakingPool1.processExpirations(true); + + const [tokenBefore] = await stakingViewer.getTokens([tokenId]); + expect(tokenBefore.expiredStake).to.equal(fixture.stakeAmount); + expect(tokenBefore.rewards.toString()).to.be.greaterThan(ethers.utils.parseEther('0.01')); + + const withdrawAssessment = { stake: false, rewards: false }; + const stakingPoolDeposits = [{ tokenId, trancheIds: [fixture.trancheId] }]; // StakingPool1 deposits + + await tokenController + .connect(manager) + .withdrawNXM(withdrawAssessment, stakingPoolDeposits, stakingPoolManagerRewards, batchSize, batchSize); + + const balanceAfter = await nxm.balanceOf(manager.address); + const [tokenAfter] = await stakingViewer.getTokens([tokenId]); + + expect(balanceAfter).to.equal(balanceBefore.add(tokenBefore.expiredStake).add(tokenBefore.rewards)); + expect(tokenAfter.expiredStake.toString()).to.equal('0'); + }); + + it('should withdraw manager rewards if stakingPoolManagerRewards is not empty', async function () { + const fixture = await loadFixture(withdrawNXMSetup); + const { stakingViewer, stakingPool1, tk: nxm, tc: tokenController } = fixture.contracts; + const { stakingPoolDeposits, batchSize } = fixture; + const [manager] = fixture.accounts.stakingPoolManagers; + + const balanceBefore = await nxm.balanceOf(manager.address); + + await increaseTime(TRANCHE_DURATION_SECONDS * 7); + await stakingPool1.processExpirations(true); + + const withdrawAssessment = { stake: false, rewards: false }; + const managerRewardsBefore = await stakingViewer.getManagerTotalRewards(manager.address); + const stakingPoolManagerRewards = [ + { poolId: 1, trancheIds: [fixture.trancheId] }, + { poolId: 2, trancheIds: [fixture.trancheId] }, + { poolId: 3, trancheIds: [fixture.trancheId] }, + ]; + + await tokenController + .connect(manager) + .withdrawNXM(withdrawAssessment, stakingPoolDeposits, stakingPoolManagerRewards, batchSize, batchSize); + + const balanceAfter = await nxm.balanceOf(manager.address); + const managerRewardsAfter = await stakingViewer.getManagerTotalRewards(manager.address); + + expect(balanceAfter).to.equal(balanceBefore.add(managerRewardsBefore)); + expect(managerRewardsAfter.toString()).to.equal('0'); + }); + + it('should withdraw all claimable NXM', async function () { + const fixture = await loadFixture(withdrawNXMSetup); + const { as: assessment, stakingViewer, stakingPool1, tk: nxm, tc: tokenController } = fixture.contracts; + const { batchSize } = fixture; + const [manager] = fixture.accounts.stakingPoolManagers; + const [tokenId] = fixture.tokenIds; // StakingPool1 stake tokenId + + await increaseTime(TRANCHE_DURATION_SECONDS * 7); + await stakingPool1.processExpirations(true); + + const balanceBefore = await nxm.balanceOf(manager.address); + const assessmentStakeBefore = await assessment.stakeOf(manager.address); + const assessmentRewardsBefore = await assessment.getRewards(manager.address); + const [tokenBefore] = await stakingViewer.getTokens([tokenId]); + const managerRewardsBefore = await stakingViewer.getManagerTotalRewards(manager.address); + + const withdrawAssessment = { stake: true, rewards: true }; + const stakingPoolDeposits = [{ tokenId, trancheIds: [fixture.trancheId] }]; // StakingPool1 deposits + const stakingPoolManagerRewards = [ + { poolId: 1, trancheIds: [fixture.trancheId] }, + { poolId: 2, trancheIds: [fixture.trancheId] }, + { poolId: 3, trancheIds: [fixture.trancheId] }, + ]; + + await tokenController + .connect(manager) + .withdrawNXM(withdrawAssessment, stakingPoolDeposits, stakingPoolManagerRewards, batchSize, batchSize); + + const balanceAfter = await nxm.balanceOf(manager.address); + const assessmentRewardsAfter = await assessment.getRewards(manager.address); + const assessmentStakeAfter = await assessment.stakeOf(manager.address); + const [tokenAfter] = await stakingViewer.getTokens([tokenId]); + const managerRewardsAfter = await stakingViewer.getManagerTotalRewards(manager.address); + + expect(balanceAfter).to.equal( + balanceBefore + .add(assessmentStakeBefore.amount) // assessment stake + .add(assessmentRewardsBefore.withdrawableAmountInNXM) // assessment rewards + .add(tokenBefore.expiredStake) // staking pool stake + .add(tokenBefore.rewards) // staking pool rewards + .add(managerRewardsBefore), // staking pool manager rewards + ); + expect(assessmentStakeAfter.amount.toString()).to.equal('0'); + expect(assessmentRewardsAfter.withdrawableAmountInNXM.toString()).to.equal('0'); + expect(tokenAfter.expiredStake.toString()).to.equal('0'); + expect(tokenAfter.rewards.toString()).to.equal('0'); + expect(managerRewardsAfter.toString()).to.equal('0'); + }); +}); diff --git a/test/integration/YieldTokenIncidents/submitClaim.js b/test/integration/YieldTokenIncidents/submitClaim.js index a6bdb3f1e5..e8fed34695 100644 --- a/test/integration/YieldTokenIncidents/submitClaim.js +++ b/test/integration/YieldTokenIncidents/submitClaim.js @@ -66,7 +66,14 @@ describe('submitClaim', function () { const coverAsset = 0; // ETH // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets yield token await transferYieldToken({ @@ -138,7 +145,14 @@ describe('submitClaim', function () { const coverAsset = 1; // DAI // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -218,7 +232,14 @@ describe('submitClaim', function () { const coverAsset = 0; // ETH // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets yield token await transferYieldToken({ @@ -273,7 +294,14 @@ describe('submitClaim', function () { const coverAsset = 1; // DAI // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -335,7 +363,14 @@ describe('submitClaim', function () { const coverAsset = 0; // ETH // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets yield token await transferYieldToken({ @@ -393,7 +428,14 @@ describe('submitClaim', function () { const coverAsset = 1; // DAI // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -456,7 +498,14 @@ describe('submitClaim', function () { const coverAsset = 0; // ETH // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // coverBuyer1 gets yield token await transferYieldToken({ @@ -582,7 +631,14 @@ describe('submitClaim', function () { const coverAsset = 0; // ETH // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets yield token await transferYieldToken({ @@ -617,7 +673,14 @@ describe('submitClaim', function () { const coverAsset = 1; // DAI // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets cover asset await transferCoverAsset({ @@ -695,7 +758,7 @@ describe('submitClaim', function () { const coverAsset = 4; // usdc // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker, productId, period, gracePeriod }); + await stake({ contracts: fixture.contracts, stakingPool: stakingPool1, staker, productId, period, gracePeriod }); // cover buyer gets cover asset await transferCoverAsset({ tokenOwner: fixture.accounts.defaultSender, coverBuyer, asset: usdc, cover }); @@ -749,7 +812,14 @@ describe('submitClaim', function () { const coverAsset = 4; // usdc // Stake to open up capacity - await stake({ stakingPool: stakingPool1, staker: staker1, productId, period, gracePeriod }); + await stake({ + contracts: fixture.contracts, + stakingPool: stakingPool1, + staker: staker1, + productId, + period, + gracePeriod, + }); // cover buyer gets cover asset await transferCoverAsset({ diff --git a/test/integration/setup.js b/test/integration/setup.js index 5c76666537..cf5b42d941 100644 --- a/test/integration/setup.js +++ b/test/integration/setup.js @@ -217,8 +217,13 @@ async function setup() { let tc = await deployProxy('Stub'); // 3. deploy StakingPool implementation + const stakingExtrasLib = await ethers.deployContract('StakingExtrasLib'); + await stakingExtrasLib.deployed(); + const spArgs = [stakingNFT, tk, cover, tc, master, stakingProducts].map(c => c.address); - const stakingPool = await ethers.deployContract('StakingPool', spArgs); + const stakingPool = await ethers.deployContract('StakingPool', spArgs, { + libraries: { StakingExtrasLib: stakingExtrasLib.address }, + }); // 4. deploy implementations and upgrade Cover, StakingProducts and DisposableTokenController proxies await upgradeProxy(cover.address, 'Cover', [coverNFT.address, stakingNFT.address, spf.address, stakingPool.address]); @@ -228,7 +233,13 @@ async function setup() { stakingProducts = await ethers.getContractAt('StakingProducts', stakingProducts.address); // TODO: get rid of DisposableTokenController and use TokenController instead with owner as operator - await upgradeProxy(tc.address, 'DisposableTokenController', [qd.address, lcr.address, spf.address, tk.address]); + await upgradeProxy(tc.address, 'DisposableTokenController', [ + qd.address, + lcr.address, + spf.address, + tk.address, + stakingNFT.address, + ]); tc = await ethers.getContractAt('DisposableTokenController', tc.address); // 5. update operators @@ -412,11 +423,17 @@ async function setup() { await master.switchGovernanceAddress(gv.address); await upgradeProxy(mr.address, 'MemberRoles', [tk.address]); - await upgradeProxy(tc.address, 'TokenController', [qd.address, lcr.address, spf.address, tk.address]); await upgradeProxy(ps.address, 'LegacyPooledStaking', [cover.address, stakingNFT.address, tk.address]); await upgradeProxy(pc.address, 'ProposalCategory'); await upgradeProxy(master.address, 'NXMaster'); await upgradeProxy(gv.address, 'Governance'); + await upgradeProxy(tc.address, 'TokenController', [ + qd.address, + lcr.address, + spf.address, + tk.address, + stakingNFT.address, + ]); // replace legacy pool after Ramm is initialized const governanceSigner = await getGovernanceSigner(gv); @@ -575,7 +592,7 @@ async function setup() { DEFAULT_POOL_FEE, // initialPoolFee DEFAULT_POOL_FEE, // maxPoolFee, DEFAULT_PRODUCTS, - '', // ipfs hash + 'ipfs-hash', // ipfs hash ); const poolId = i + 1; diff --git a/test/integration/utils/staking.js b/test/integration/utils/staking.js index 8e75cbb106..f6cb22fafe 100644 --- a/test/integration/utils/staking.js +++ b/test/integration/utils/staking.js @@ -22,7 +22,9 @@ async function stakeOnly({ stakingPool, staker, period, gracePeriod, trancheIdOf ); } -async function stake({ stakingPool, staker, productId, period, gracePeriod, amount = 0 }) { +async function stake({ contracts, stakingPool, staker, productId, period, gracePeriod, amount = 0 }) { + const { stakingProducts } = contracts; + // Staking inputs const stakingAmount = amount !== 0 ? BigNumber.from(amount) : parseEther('10000'); const lastBlock = await ethers.provider.getBlock('latest'); @@ -47,8 +49,8 @@ async function stake({ stakingPool, staker, productId, period, gracePeriod, amou // Set staked products const managerSigner = await ethers.getSigner(await stakingPool.manager()); - const stakingProducts = await ethers.getContractAt('StakingProducts', await stakingPool.stakingProducts()); - await stakingProducts.connect(managerSigner).setProducts(await stakingPool.getPoolId(), [stakingProductParams]); + const poolId = await stakingPool.getPoolId(); + await stakingProducts.connect(managerSigner).setProducts(poolId, [stakingProductParams]); } module.exports = { diff --git a/test/unit/Assessment/castVotes.js b/test/unit/Assessment/castVotes.js index b480c4272f..1d6859dab3 100644 --- a/test/unit/Assessment/castVotes.js +++ b/test/unit/Assessment/castVotes.js @@ -18,12 +18,12 @@ describe('castVotes', function () { await assessment.connect(user).stake(parseEther('100')); await individualClaims.submitClaim(0, 0, parseEther('100'), ''); await assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0); - await expect(assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'Already voted', - ); - await expect(assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'Already voted', - ); + + const castVotesTrue = assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesTrue).to.be.revertedWithCustomError(assessment, 'AlreadyVoted'); + + const castVotesFalse = assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesFalse).to.be.revertedWithCustomError(assessment, 'AlreadyVoted'); }); it('reverts if the user has no stake', async function () { @@ -31,12 +31,12 @@ describe('castVotes', function () { const { assessment, individualClaims } = fixture.contracts; const user = fixture.accounts.members[0]; await individualClaims.submitClaim(0, 0, parseEther('100'), ''); - await expect(assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'A stake is required to cast votes', - ); - await expect(assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'A stake is required to cast votes', - ); + + const castVotesTrue = assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesTrue).to.be.revertedWithCustomError(assessment, 'StakeRequired'); + + const castVotesFalse = assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesFalse).to.be.revertedWithCustomError(assessment, 'StakeRequired'); }); it('reverts if the voting period has ended', async function () { @@ -50,12 +50,12 @@ describe('castVotes', function () { const { poll } = await assessment.assessments(0); await setTime(poll.end); } - await expect(assessment.connect(user1).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'Voting is closed', - ); - await expect(assessment.connect(user1).castVotes([0], [ASSESSMENT_DATA_HASH], [false], 0)).to.be.revertedWith( - 'Voting is closed', - ); + + const castVotesTrue0 = assessment.connect(user1).castVotes([0], [true], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesTrue0).to.be.revertedWithCustomError(assessment, 'VotingClosed'); + + const castVotesFalse0 = assessment.connect(user1).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesFalse0).to.be.revertedWithCustomError(assessment, 'VotingClosed'); await individualClaims.submitClaim(1, 0, parseEther('100'), ''); const { timestamp } = await ethers.provider.getBlock('latest'); @@ -65,23 +65,24 @@ describe('castVotes', function () { const { poll } = await assessment.assessments(1); await setTime(poll.end); } - await expect(assessment.connect(user2).castVotes([1], [true], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'Voting is closed', - ); - await expect(assessment.connect(user2).castVotes([1], [false], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'Voting is closed', - ); + + const castVotesTrue1 = assessment.connect(user2).castVotes([1], [true], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesTrue1).to.be.revertedWithCustomError(assessment, 'VotingClosed'); + + const castVotesFalse1 = assessment.connect(user2).castVotes([1], [false], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesFalse1).to.be.revertedWithCustomError(assessment, 'VotingClosed'); }); it('reverts if the first vote is deny', async function () { const fixture = await loadFixture(setup); const { assessment, individualClaims } = fixture.contracts; const user = fixture.accounts.members[0]; + await assessment.connect(user).stake(parseEther('100')); await individualClaims.submitClaim(0, 0, parseEther('100'), ''); - await expect(assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0)).to.be.revertedWith( - 'At least one accept vote is required to vote deny', - ); + + const castVotesDeny = assessment.connect(user).castVotes([0], [false], [ASSESSMENT_DATA_HASH], 0); + await expect(castVotesDeny).to.be.revertedWithCustomError(assessment, 'AcceptVoteRequired'); }); it('resets the voting period to minVotingPeriodInDays after the first accept vote', async function () { @@ -378,9 +379,10 @@ describe('castVotes', function () { const { assessment } = fixture.contracts; const [user] = fixture.accounts.members; - await expect( - assessment.connect(user).castVotes([0], [true, true], [ASSESSMENT_DATA_HASH, ASSESSMENT_DATA_HASH], 0), - ).to.revertedWith('The lengths of the assessment ids and votes arrays mismatch'); + const ipfsHashes = [ASSESSMENT_DATA_HASH, ASSESSMENT_DATA_HASH]; + const castVotes = assessment.connect(user).castVotes([0], [true, true], ipfsHashes, 0); + + await expect(castVotes).to.be.revertedWithCustomError(assessment, 'AssessmentIdsVotesLengthMismatch'); }); it('reverts if array length of assessments id and ipfsHashes does not match', async function () { @@ -388,9 +390,9 @@ describe('castVotes', function () { const { assessment } = fixture.contracts; const [user] = fixture.accounts.members; - await expect( - assessment.connect(user).castVotes([0], [true], [ASSESSMENT_DATA_HASH, ASSESSMENT_DATA_HASH], 0), - ).to.revertedWith('The lengths of the assessment ids and ipfs assessment data hashes arrays mismatch'); + const ipfsHashes = [ASSESSMENT_DATA_HASH, ASSESSMENT_DATA_HASH]; + const castVotes = assessment.connect(user).castVotes([0], [true], ipfsHashes, 0); + await expect(castVotes).to.be.revertedWithCustomError(assessment, 'AssessmentIdsIpfsLengthMismatch'); }); it('does not revert on empty arrays', async function () { diff --git a/test/unit/Assessment/helpers.js b/test/unit/Assessment/helpers.js index 008337bd3e..9c559dbf7f 100644 --- a/test/unit/Assessment/helpers.js +++ b/test/unit/Assessment/helpers.js @@ -125,7 +125,7 @@ const finalizePoll = async assessment => { const { timestamp } = await ethers.provider.getBlock('latest'); const { minVotingPeriodInDays, payoutCooldownInDays } = await assessment.config(); - await setTime(timestamp + daysToSeconds(minVotingPeriodInDays + payoutCooldownInDays)); + await setTime(timestamp + daysToSeconds(minVotingPeriodInDays + payoutCooldownInDays) + 1); }; const generateRewards = async ({ assessment, individualClaims, staker }) => { diff --git a/test/unit/Assessment/processFraud.js b/test/unit/Assessment/processFraud.js index bd2db34a76..3e477fb9b6 100644 --- a/test/unit/Assessment/processFraud.js +++ b/test/unit/Assessment/processFraud.js @@ -32,9 +32,8 @@ describe('processFraud', function () { fraudCount: 0, merkleTree, }); - await expect( - assessment.processFraud(0, proof, honestMember.address, 0, parseEther('100'), 0, 100), - ).to.be.revertedWith('Invalid merkle proof'); + const processFraud = assessment.processFraud(0, proof, honestMember.address, 0, parseEther('100'), 0, 100); + await expect(processFraud).to.be.revertedWithCustomError(assessment, 'InvalidMerkleProof'); } { @@ -45,9 +44,8 @@ describe('processFraud', function () { fraudCount: 0, merkleTree, }); - await expect( - assessment.processFraud(0, proof, fraudulentMember.address, 0, parseEther('200'), 0, 100), - ).to.be.revertedWith('Invalid merkle proof'); + const processFraud = assessment.processFraud(0, proof, fraudulentMember.address, 0, parseEther('200'), 0, 100); + await expect(processFraud).to.be.revertedWithCustomError(assessment, 'InvalidMerkleProof'); } { @@ -58,9 +56,8 @@ describe('processFraud', function () { fraudCount: 1, merkleTree, }); - await expect( - assessment.processFraud(0, proof, fraudulentMember.address, 1, parseEther('100'), 0, 100), - ).to.be.revertedWith('Invalid merkle proof'); + const processFraud = assessment.processFraud(0, proof, fraudulentMember.address, 1, parseEther('100'), 0, 100); + await expect(processFraud).to.be.revertedWithCustomError(assessment, 'InvalidMerkleProof'); } { @@ -71,9 +68,8 @@ describe('processFraud', function () { fraudCount: 0, merkleTree, }); - await expect( - assessment.processFraud(0, proof, fraudulentMember.address, 0, parseEther('100'), 0, 100), - ).not.to.be.revertedWith('Invalid merkle proof'); + const processFraud = assessment.processFraud(0, proof, fraudulentMember.address, 0, parseEther('100'), 0, 100); + await expect(processFraud).to.not.be.revertedWithCustomError(assessment, 'InvalidMerkleProof'); } }); diff --git a/test/unit/Assessment/setup.js b/test/unit/Assessment/setup.js index 8ca2c819c1..8edca151f1 100644 --- a/test/unit/Assessment/setup.js +++ b/test/unit/Assessment/setup.js @@ -2,6 +2,7 @@ const { ethers } = require('hardhat'); const { hex } = require('../../../lib/helpers'); const { Role } = require('../../../lib/constants'); const { getAccounts } = require('../../utils/accounts'); +const { impersonateAccount, setEtherBalance } = require('../utils').evm; const { parseEther } = ethers.utils; async function setup() { @@ -75,6 +76,10 @@ async function setup() { const config = await assessment.config(); + await impersonateAccount(tokenController.address); + await setEtherBalance(tokenController.address, parseEther('100')); + accounts.tokenControllerSigner = await ethers.getSigner(tokenController.address); + return { config, accounts, diff --git a/test/unit/Assessment/unstake.js b/test/unit/Assessment/unstake.js index 8cdb09975f..760526dd3c 100644 --- a/test/unit/Assessment/unstake.js +++ b/test/unit/Assessment/unstake.js @@ -5,7 +5,7 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { setup } = require('./setup'); const { parseEther } = ethers.utils; -const daysToSeconds = days => days * 24 * 60 * 60; +const ONE_DAY_SECONDS = 24 * 60 * 60; describe('unstake', function () { it("decreases the user's stake", async function () { @@ -42,8 +42,7 @@ describe('unstake', function () { it('transfers the staked NXM to the provided address', async function () { const fixture = await loadFixture(setup); const { assessment, nxm } = fixture.contracts; - const user1 = fixture.accounts.members[0]; - const user2 = fixture.accounts.members[1]; + const [user1, user2] = fixture.accounts.members; await assessment.connect(user1).stake(parseEther('100')); { @@ -65,33 +64,36 @@ describe('unstake', function () { const fixture = await loadFixture(setup); const { assessment, individualClaims } = fixture.contracts; const user = fixture.accounts.members[0]; - await assessment.connect(user).stake(parseEther('100')); - await individualClaims.submitClaim(0, 0, parseEther('100'), ''); + const amount = parseEther('100'); + + await assessment.connect(user).stake(amount); + await individualClaims.submitClaim(0, 0, amount, ''); + + const { timestamp } = await ethers.provider.getBlock('latest'); // store the block.timestamp on time of vote await assessment.connect(user).castVotes([0], [true], ['Assessment data hash'], 0); - await expect(assessment.connect(user).unstake(parseEther('100'), user.address)).to.be.revertedWith( - 'Stake is in lockup period', - ); + + const unstakeBeforeExpiry = assessment.connect(user).unstake(amount, user.address); + await expect(unstakeBeforeExpiry).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); const { stakeLockupPeriodInDays } = await assessment.config(); - const { timestamp } = await ethers.provider.getBlock('latest'); - for (let i = 1; i < stakeLockupPeriodInDays; i++) { - await setTime(timestamp + daysToSeconds(i)); - await expect(assessment.connect(user).unstake(parseEther('100'), user.address)).to.be.revertedWith( - 'Stake is in lockup period', - ); + for (let dayCount = 1; dayCount < stakeLockupPeriodInDays; dayCount++) { + await setTime(timestamp + dayCount * ONE_DAY_SECONDS); + const unstake = assessment.connect(user).unstake(amount, user.address); + await expect(unstake).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); } - await setTime(timestamp + daysToSeconds(stakeLockupPeriodInDays)); - await expect(assessment.connect(user).unstake(parseEther('100'), user.address)).not.to.be.revertedWith( - 'Stake is in lockup period', - ); + + await setTime(timestamp + stakeLockupPeriodInDays * ONE_DAY_SECONDS); + const unstakeAtExpiry = assessment.connect(user).unstake(amount, user.address); + await expect(unstakeAtExpiry).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); }); it('reverts if system is paused', async function () { const fixture = await loadFixture(setup); const { assessment, master } = fixture.contracts; + const [user] = fixture.accounts.members; await master.setEmergencyPause(true); - await expect(assessment.stake(parseEther('100'))).to.revertedWith('System is paused'); + await expect(assessment.unstake(parseEther('100'), user.address)).to.revertedWith('System is paused'); }); it('does not revert if amount is 0', async function () { @@ -103,14 +105,24 @@ describe('unstake', function () { await expect(assessment.connect(user).unstake(0, user.address)).to.not.reverted; }); - it('reverts if amount is bigger than the stake', async function () { + it('reverts with InvalidAmount user has no stake to unstake', async function () { const fixture = await loadFixture(setup); const { assessment } = fixture.contracts; - const user = fixture.accounts.members[0]; - await assessment.connect(user).stake(parseEther('100')); + const [user] = fixture.accounts.members; + // no stake + const unstake = assessment.connect(user).unstake(parseEther('50'), user.address); + await expect(unstake).to.be.revertedWithCustomError(assessment, 'InvalidAmount').withArgs(0); + }); + + it('reverts with InvalidAmount if amount is bigger than the stake', async function () { + const fixture = await loadFixture(setup); + const { assessment } = fixture.contracts; + const [user] = fixture.accounts.members; + const stakeAmount = parseEther('100'); + await assessment.connect(user).stake(stakeAmount); - // reverts with math underflow check: panic code 0x11 - await expect(assessment.connect(user).unstake(parseEther('150'), user.address)).to.be.reverted; + const unstake = assessment.connect(user).unstake(stakeAmount.add(1), user.address); + await expect(unstake).to.be.revertedWithCustomError(assessment, 'InvalidAmount').withArgs(stakeAmount); }); it('emits StakeWithdrawn event with staker, destination and amount', async function () { @@ -138,10 +150,12 @@ describe('unstake', function () { const fixture = await loadFixture(setup); const { nxm, assessment } = fixture.contracts; const [user, otherUser] = fixture.accounts.members; + + await assessment.connect(user).stake(parseEther('100')); await nxm.setLock(user.address, 100); - await expect(assessment.connect(user).unstake(parseEther('100'), otherUser.address)).to.be.revertedWith( - 'Assessment: NXM is locked for voting in governance', - ); + + const unstake = assessment.connect(user).unstake(parseEther('100'), otherUser.address); + await expect(unstake).to.be.revertedWithCustomError(assessment, 'StakeLockedForGovernance'); }); it('allows to unstake to own address while NXM is locked for voting in governance', async function () { diff --git a/test/unit/Assessment/unstakeAllFor.js b/test/unit/Assessment/unstakeAllFor.js new file mode 100644 index 0000000000..299f8a4e4f --- /dev/null +++ b/test/unit/Assessment/unstakeAllFor.js @@ -0,0 +1,133 @@ +const { expect } = require('chai'); +const { ethers } = require('hardhat'); +const { setTime } = require('./helpers'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { setup } = require('./setup'); + +const { parseEther } = ethers.utils; +const ONE_DAY_SECONDS = 24 * 60 * 60; + +describe('unstakeAllFor', function () { + it("decreases the staker's stake to 0", async function () { + const fixture = await loadFixture(setup); + const { assessment } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + await assessment.connect(user).stake(parseEther('100')); + + await assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + const { amount } = await assessment.stakeOf(user.address); + expect(amount).to.be.equal(parseEther('0')); + }); + + it('transfers all the staked NXM to the provided address', async function () { + const fixture = await loadFixture(setup); + const { assessment, nxm } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user, otherUser] = fixture.accounts.members; + const stakeAmount = parseEther('100'); + await assessment.connect(user).stake(stakeAmount); + + const user1BalanceBefore = await nxm.balanceOf(user.address); + const user2BalanceBefore = await nxm.balanceOf(otherUser.address); + + await assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + + const user1BalanceAfter = await nxm.balanceOf(user.address); + const user2BalanceAfter = await nxm.balanceOf(otherUser.address); + + expect(user1BalanceAfter).to.be.equal(user1BalanceBefore.add(stakeAmount)); + expect(user2BalanceAfter).to.be.equal(user2BalanceBefore); + }); + + it("reverts if less than stakeLockupPeriodInDays passed since the staker's last vote", async function () { + const fixture = await loadFixture(setup); + const { assessment, individualClaims } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + const amount = parseEther('100'); + + await assessment.connect(user).stake(amount); + await individualClaims.submitClaim(0, 0, amount, ''); + + const { timestamp } = await ethers.provider.getBlock('latest'); // store the block.timestamp on time of vote + await assessment.connect(user).castVotes([0], [true], ['Assessment data hash'], 0); + + const unstakeForBeforeExpiry = assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + await expect(unstakeForBeforeExpiry).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); + + const { stakeLockupPeriodInDays } = await assessment.config(); + for (let dayCount = 1; dayCount < stakeLockupPeriodInDays; dayCount++) { + await setTime(timestamp + dayCount * ONE_DAY_SECONDS); + const unstakeFor = assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + await expect(unstakeFor).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); + } + + await setTime(timestamp + stakeLockupPeriodInDays * ONE_DAY_SECONDS); + const unstakeForAtExpiry = assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + await expect(unstakeForAtExpiry).to.be.revertedWithCustomError(assessment, 'StakeLockedForAssessment'); + }); + + it('reverts if system is paused', async function () { + const fixture = await loadFixture(setup); + const { assessment, master } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + await master.setEmergencyPause(true); + + const unstakeFor = assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + await expect(unstakeFor).to.be.revertedWith('System is paused'); + }); + + it('reverts if called by any address other than the TokenController', async function () { + const fixture = await loadFixture(setup); + const { assessment } = fixture.contracts; + const [user, otherAddress] = fixture.accounts.members; + + const unstakeFor = assessment.connect(otherAddress).unstakeAllFor(user.address); + await expect(unstakeFor).to.be.revertedWithCustomError(assessment, 'OnlyTokenController'); + }); + + it('does NOT revert if user has no stake to unstake', async function () { + const fixture = await loadFixture(setup); + const { assessment } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + + const assessmentStake = await assessment.stakeOf(user.address); + expect(assessmentStake.amount.toString()).to.equal('0'); + + await expect(assessment.connect(tokenControllerSigner).unstakeAllFor(user.address)).to.not.be.reverted; + }); + + it('emits StakeWithdrawn event with staker, destination and amount', async function () { + const fixture = await loadFixture(setup); + const { assessment } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + + const stakeAmount = parseEther('100'); + await assessment.connect(user).stake(stakeAmount); + + await expect(assessment.connect(tokenControllerSigner).unstakeAllFor(user.address)) + .to.emit(assessment, 'StakeWithdrawn') + .withArgs(user.address, user.address, stakeAmount); + }); + + it('allows to unstake while NXM is locked for voting in governance (own address)', async function () { + const fixture = await loadFixture(setup); + const { nxm, assessment } = fixture.contracts; + const { tokenControllerSigner } = fixture.accounts; + const [user] = fixture.accounts.members; + const amount = parseEther('100'); + + await assessment.connect(user).stake(amount); + const balanceBefore = await nxm.balanceOf(user.address); + + await nxm.setLock(user.address, 100); + await assessment.connect(tokenControllerSigner).unstakeAllFor(user.address); + + const balanceAfter = await nxm.balanceOf(user.address); + expect(balanceAfter).to.be.equal(balanceBefore.add(amount)); + }); +}); diff --git a/test/unit/Assessment/withdrawRewards.js b/test/unit/Assessment/withdrawRewards.js index b731950159..532dafee2c 100644 --- a/test/unit/Assessment/withdrawRewards.js +++ b/test/unit/Assessment/withdrawRewards.js @@ -15,10 +15,11 @@ describe('withdrawRewards', function () { const fixture = await loadFixture(setup); const { assessment } = fixture.contracts; const [user] = fixture.accounts.members; + await assessment.connect(user).stake(parseEther('10')); - await expect(assessment.connect(user).withdrawRewards(user.address, 0)).to.be.revertedWith( - 'No withdrawable rewards', - ); + + const withdrawRewards = assessment.connect(user).withdrawRewards(user.address, 0); + await expect(withdrawRewards).to.be.revertedWithCustomError(assessment, 'NoWithdrawableRewards'); }); it("allows any address to call but the reward is withdrawn to the staker's address", async function () { @@ -198,19 +199,6 @@ describe('withdrawRewards', function () { await expect(assessment.connect(staker).withdrawRewards(staker.address, 0)).to.be.revertedWith('System is paused'); }); - it('reverts if staker is not a member', async function () { - const fixture = await loadFixture(setup); - const { assessment, individualClaims } = fixture.contracts; - const [staker] = fixture.accounts.members; - const [nonMember] = fixture.accounts.nonMembers; - - await generateRewards({ assessment, individualClaims, staker }); - - await expect(assessment.connect(staker).withdrawRewards(nonMember.address, 0)).to.be.revertedWith( - 'Destination address is not a member', - ); - }); - it('reverts if assessment rewards already claimed', async function () { const fixture = await loadFixture(setup); const { assessment, individualClaims, nxm } = fixture.contracts; @@ -233,9 +221,8 @@ describe('withdrawRewards', function () { expect(stakerBalanceAfter).to.be.equal(stakerBalanceBefore.add(totalRewardInNXM)); expect(stakeOfAfter.rewardsWithdrawableFromIndex).to.be.equal(stakeOfBefore.rewardsWithdrawableFromIndex.add(1)); - await expect(assessment.connect(staker).withdrawRewards(staker.address, 0)).to.be.revertedWith( - 'No withdrawable rewards', - ); + const withdrawRewards = assessment.connect(staker).withdrawRewards(staker.address, 0); + await expect(withdrawRewards).to.be.revertedWithCustomError(assessment, 'NoWithdrawableRewards'); }); it('withdraws zero amount if poll is not final', async function () { diff --git a/test/unit/Assessment/withdrawRewardsTo.js b/test/unit/Assessment/withdrawRewardsTo.js index 3eaad117b9..5cc7475cae 100644 --- a/test/unit/Assessment/withdrawRewardsTo.js +++ b/test/unit/Assessment/withdrawRewardsTo.js @@ -13,10 +13,11 @@ describe('withdrawRewardsTo', function () { const fixture = await loadFixture(setup); const { assessment } = fixture.contracts; const [user] = fixture.accounts.members; + await assessment.connect(user).stake(parseEther('10')); - await expect(assessment.connect(user).withdrawRewardsTo(user.address, 0)).to.be.revertedWith( - 'No withdrawable rewards', - ); + + const withdrawRewardsTo = assessment.connect(user).withdrawRewardsTo(user.address, 0); + await expect(withdrawRewardsTo).to.be.revertedWithCustomError(assessment, 'NoWithdrawableRewards'); }); it('reverts when not called by the owner of the rewards ', async function () { @@ -32,10 +33,10 @@ describe('withdrawRewardsTo', function () { const { totalRewardInNXM } = await assessment.assessments(0); const nonMemberBalanceBefore = await nxm.balanceOf(nonMember.address); const stakerBalanceBefore = await nxm.balanceOf(staker.address); - await setNextBlockBaseFee('0'); - await expect( - assessment.connect(nonMember).withdrawRewardsTo(staker.address, 0, { gasPrice: 0 }), - ).to.be.revertedWith('No withdrawable rewards'); + + const withdrawRewardsTo = assessment.connect(nonMember).withdrawRewardsTo(staker.address, 0); + await expect(withdrawRewardsTo).to.be.revertedWithCustomError(assessment, 'NoWithdrawableRewards'); + await setNextBlockBaseFee('0'); await expect(assessment.connect(staker).withdrawRewardsTo(staker.address, 0, { gasPrice: 0 })).not.to.be.reverted; const nonMemberBalanceAfter = await nxm.balanceOf(nonMember.address); @@ -194,23 +195,6 @@ describe('withdrawRewardsTo', function () { } }); - it('reverts if the destination address is not a member', async function () { - const fixture = await loadFixture(setup); - const { assessment, individualClaims } = fixture.contracts; - const [user1] = fixture.accounts.members; - const nonMember = '0xDECAF00000000000000000000000000000000000'; - - await individualClaims.connect(user1).submitClaim(0, 0, parseEther('100'), ''); - await assessment.connect(user1).stake(parseEther('10')); - await assessment.connect(user1).castVotes([0], [true], ['Assessment data hash'], 0); - - await finalizePoll(assessment); - - await expect(assessment.connect(user1).withdrawRewardsTo(nonMember, 0)).to.be.revertedWith( - 'Destination address is not a member', - ); - }); - it('should withdraw multiple rewards consecutively', async function () { const fixture = await loadFixture(setup); const { nxm, assessment, individualClaims } = fixture.contracts; @@ -338,9 +322,8 @@ describe('withdrawRewardsTo', function () { expect(stakerBalanceAfter).to.be.equal(stakerBalanceBefore.add(totalRewardInNXM)); expect(stakeOfAfter.rewardsWithdrawableFromIndex).to.be.equal(stakeOfBefore.rewardsWithdrawableFromIndex.add(1)); - await expect(assessment.connect(staker).withdrawRewardsTo(staker.address, 0)).to.be.revertedWith( - 'No withdrawable rewards', - ); + const withdrawRewardsTo = assessment.connect(staker).withdrawRewardsTo(staker.address, 0); + await expect(withdrawRewardsTo).to.be.revertedWithCustomError(assessment, 'NoWithdrawableRewards'); }); it('withdraws zero amount if poll is not final', async function () { diff --git a/test/unit/Cover/helpers.js b/test/unit/Cover/helpers.js index 4cfee31ad1..d215872a6e 100644 --- a/test/unit/Cover/helpers.js +++ b/test/unit/Cover/helpers.js @@ -31,7 +31,7 @@ async function createStakingPool( DEFAULT_POOL_FEE, // initialPoolFee DEFAULT_POOL_FEE, // maxPoolFee, productInitializationParams, - '', // ipfsDescriptionHash + 'ipfsDescriptionHash', ); const stakingPoolId = await stakingPoolFactory.stakingPoolCount(); diff --git a/test/unit/StakingPool/burnStake.js b/test/unit/StakingPool/burnStake.js index b94c3c1198..4613434676 100644 --- a/test/unit/StakingPool/burnStake.js +++ b/test/unit/StakingPool/burnStake.js @@ -2,10 +2,10 @@ const { expect } = require('chai'); const { ethers } = require('hardhat'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const { getTranches, moveTimeToNextTranche, BUCKET_DURATION } = require('./helpers'); +const { getTranches, moveTimeToNextBucket, moveTimeToNextTranche, BUCKET_DURATION, setTime } = require('./helpers'); const { daysToSeconds } = require('../../../lib/helpers'); -const { AddressZero, Two } = ethers.constants; +const { AddressZero, Two, Zero } = ethers.constants; const { parseEther } = ethers.utils; const MaxUint32 = Two.pow(32).sub(1); @@ -27,7 +27,6 @@ const poolInitParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [initialProduct], - ipfsDescriptionHash: 'Description Hash', }; const productTypeFixture = { @@ -77,7 +76,7 @@ async function burnStakeSetup() { const fixture = await loadFixture(setup); const { stakingPool, stakingProducts, coverProducts } = fixture; const [staker] = fixture.accounts.members; - const { poolId, initialPoolFee, maxPoolFee, products, ipfsDescriptionHash } = poolInitParams; + const { poolId, initialPoolFee, maxPoolFee, products } = poolInitParams; await coverProducts.setProductType(productTypeFixture, initialProduct.productId); await coverProducts.setProduct(coverProductTemplate, initialProduct.productId); @@ -87,7 +86,6 @@ async function burnStakeSetup() { initialPoolFee, maxPoolFee, poolId, - ipfsDescriptionHash, ); await stakingProducts.connect(fixture.stakingProductsSigner).setInitialProducts(poolId, products); @@ -398,7 +396,43 @@ describe('burnStake', function () { } }); - it('correctly deallocates expiring cover amounts', async function () { + it('does not deallocate after cover expiry', async function () { + const fixture = await loadFixture(burnStakeSetup); + const { stakingPool } = fixture; + const { productId, period } = allocationRequestParams; + + // get to a new bucket to avoid expiration issues + await moveTimeToNextBucket(1); + + const allocationId = await stakingPool.getNextAllocationId(); + const allocateTx = await stakingPool + .connect(fixture.coverSigner) + .requestAllocation(stakedNxmAmount, 0, allocationRequestParams); + + const { blockNumber } = await allocateTx.wait(); + const { timestamp: allocationTimestamp } = await ethers.provider.getBlock(blockNumber); + + const initialAllocations = await stakingPool.getActiveAllocations(productId); + const initiallyAllocatedTotal = initialAllocations.reduce((acc, val) => acc.add(val), Zero); + + const burnParams = { + allocationId, + productId, + start: allocationTimestamp, + period, + deallocationAmount: initiallyAllocatedTotal.div(2), // claimed half of the cover amount + }; + + await setTime(allocationTimestamp + period + 1); + await stakingPool.connect(fixture.coverSigner).burnStake(0, burnParams); + + const finalAllocations = await stakingPool.getActiveAllocations(productId); + const finallyAllocatedTotal = finalAllocations.reduce((acc, val) => acc.add(val), Zero); + + expect(initiallyAllocatedTotal).to.equal(finallyAllocatedTotal); + }); + + it('does not deallocate if in grace period', async function () { const fixture = await loadFixture(burnStakeSetup); const { stakingPool } = fixture; const { NXM_PER_ALLOCATION_UNIT } = fixture.config; diff --git a/test/unit/StakingPool/calculateNewRewardShares.js b/test/unit/StakingPool/calculateNewRewardShares.js deleted file mode 100644 index c5495d35ec..0000000000 --- a/test/unit/StakingPool/calculateNewRewardShares.js +++ /dev/null @@ -1,170 +0,0 @@ -const { expect } = require('chai'); -const { - ethers: { - utils: { parseUnits }, - }, -} = require('hardhat'); -const { TRANCHE_DURATION } = require('./helpers'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const setup = require('./setup'); - -describe('calculateNewRewardShares', function () { - it('grants bonus shares proportionally to the time left of the first active tranche', async function () { - const fixture = await loadFixture(setup); - const { stakingPool, config } = fixture; - - const blockTimestamps = [ - 1651104000, // Tranche 210 begins - 1652676480, // 1 fifth elapsed - 1653724800, // 1 third elapsed - 1655035200, // 1 half elapsed - 1658966399, // Last second of tranche 210 - ]; - - const newStakeShares = parseUnits('27644437'); - const firstActiveTrancheId = 210; - const firstActiveTrancheEnd = (firstActiveTrancheId + 1) * TRANCHE_DURATION; - - const expectedNewShares = blockTimestamps.map(t => { - const firstActiveTrancheTimeLeft = firstActiveTrancheEnd - t; - const expectedNewShares = newStakeShares.add( - newStakeShares - .mul(config.REWARD_BONUS_PER_TRANCHE_RATIO) - .div(config.REWARD_BONUS_PER_TRANCHE_DENOMINATOR) - .mul(firstActiveTrancheTimeLeft) - .div(TRANCHE_DURATION), - ); - return expectedNewShares; - }); - - let prevNewShares; - for (const i in expectedNewShares) { - const newRewardsShares = await stakingPool.calculateNewRewardShares( - 0, // initialStakeShares - newStakeShares, - firstActiveTrancheId, - firstActiveTrancheId, - blockTimestamps[i], - ); - - expect(newRewardsShares).to.be.equal(expectedNewShares[i]); - - // As time elapses, the new shares are decreased - if (prevNewShares) { - expect(prevNewShares).to.be.gt(newRewardsShares); - } - prevNewShares = newRewardsShares; - } - }); - - it('grants REWARD_BONUS_PER_TRANCHE_RATIO worth of bonus shares for the entirety of each tranche', async function () { - const fixture = await loadFixture(setup); - const { stakingPool, config } = fixture; - - const firstActiveTrancheStart = 1651104000; - const newStakeShares = parseUnits('27644437'); - const firstActiveTrancheId = 210; - - for (let i = 0; i <= 8; i++) { - const newRewardsShares = await stakingPool.calculateNewRewardShares( - 0, // initialStakeShares - newStakeShares, - firstActiveTrancheId + i, - firstActiveTrancheId + i, - firstActiveTrancheStart, - ); - - expect(newRewardsShares).to.be.equal( - newStakeShares.add( - newStakeShares - .mul(i + 1) - .mul(config.REWARD_BONUS_PER_TRANCHE_RATIO) - .div(config.REWARD_BONUS_PER_TRANCHE_DENOMINATOR), - ), - ); - } - }); - - it('grants new rewards shares for new stake shares but not for already existing ones', async function () { - const fixture = await loadFixture(setup); - const { stakingPool, config } = fixture; - - const firstActiveTrancheStart = 1651104000; - const initialStakeShares = parseUnits('27644437'); - const newStakeShares = parseUnits('877'); - const firstActiveTrancheId = 210; - - for (let i = 0; i <= 8; i++) { - const newRewardsShares = await stakingPool.calculateNewRewardShares( - initialStakeShares, - newStakeShares, - firstActiveTrancheId + i, - firstActiveTrancheId + i, - firstActiveTrancheStart, - ); - - expect(newRewardsShares).to.be.equal( - newStakeShares.add( - newStakeShares - .mul(i + 1) - .mul(config.REWARD_BONUS_PER_TRANCHE_RATIO) - .div(config.REWARD_BONUS_PER_TRANCHE_DENOMINATOR), - ), - ); - } - }); - - it('grants new bonus shares for extending the period of an existing deposit', async function () { - const fixture = await loadFixture(setup); - const { stakingPool, config } = fixture; - - const firstActiveTrancheStart = 1651104000; - const initialStakeShares = parseUnits('27644437'); - const firstActiveTrancheId = 210; - - for (let i = 1; i <= 8; i++) { - const newRewardsShares = await stakingPool.calculateNewRewardShares( - initialStakeShares, - 0, - firstActiveTrancheId, - firstActiveTrancheId + i, - firstActiveTrancheStart, - ); - - expect(newRewardsShares).to.be.equal( - initialStakeShares - .mul(i) - .mul(config.REWARD_BONUS_PER_TRANCHE_RATIO) - .div(config.REWARD_BONUS_PER_TRANCHE_DENOMINATOR), - ); - } - }); - - it('new reward shares are always grater than or equal to the new stake shares', async function () { - const fixture = await loadFixture(setup); - const { stakingPool } = fixture; - - const blockTimestamps = [ - 1651104000, // Tranche 210 begins - 1652676480, // 1 fifth elapsed - 1653724800, // 1 third elapsed - 1655035200, // 1 half elapsed - 1658966399, // Last second of tranche 210 - ]; - - const newStakeShares = parseUnits('27644437'); - const firstActiveTrancheId = 210; - - for (const i in blockTimestamps) { - const newRewardsShares = await stakingPool.calculateNewRewardShares( - 0, // initialStakeShares - newStakeShares, - firstActiveTrancheId, - firstActiveTrancheId, - blockTimestamps[i], - ); - - expect(newRewardsShares).to.be.gte(newStakeShares); - } - }); -}); diff --git a/test/unit/StakingPool/constructor.js b/test/unit/StakingPool/constructor.js index d5deeff58c..769365a57e 100644 --- a/test/unit/StakingPool/constructor.js +++ b/test/unit/StakingPool/constructor.js @@ -4,18 +4,18 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); describe('constructor', function () { - it('should set nxm, cover and tokenController addresses correctly', async function () { + // currently cannot be tested because the addresses have been set as internal + it.skip('should set nxm, cover and tokenController addresses correctly', async function () { const fixture = await loadFixture(setup); const { stakingProducts, stakingNFT, nxm, cover, tokenController, master } = fixture; - const StakingPool = await ethers.getContractFactory('StakingPool'); - const stakingPool = await StakingPool.deploy( - stakingNFT.address, - nxm.address, - cover.address, - tokenController.address, - master.address, - stakingProducts.address, + const stakingExtrasLib = await ethers.deployContract('StakingExtrasLib'); + await stakingExtrasLib.deployed(); + + const stakingPool = await ethers.deployContract( + 'StakingPool', + [stakingNFT, nxm, cover, tokenController, master, stakingProducts].map(c => c.address), + { libraries: { StakingExtrasLib: stakingExtrasLib.address } }, ); const stakingNFTAddress = await stakingPool.stakingNFT(); diff --git a/test/unit/StakingPool/depositTo.js b/test/unit/StakingPool/depositTo.js index edb55514f8..988897b576 100644 --- a/test/unit/StakingPool/depositTo.js +++ b/test/unit/StakingPool/depositTo.js @@ -2,7 +2,7 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { increaseTime } = require('../utils').evm; -const { getTranches, getNewRewardShares, estimateStakeShares, setTime, TRANCHE_DURATION } = require('./helpers'); +const { getTranches, calculateStakeShares, setTime, TRANCHE_DURATION } = require('./helpers'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); const { daysToSeconds } = require('../utils').helpers; @@ -31,7 +31,6 @@ const poolInitParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [productParams], - ipfsDescriptionHash: 'Description Hash', }; const managerDepositId = 0; @@ -43,14 +42,13 @@ async function depositToSetup() { const fixture = await loadFixture(setup); const { stakingPool, stakingProducts, tokenController } = fixture; const { defaultSender: manager } = fixture.accounts; - const { poolId, initialPoolFee, maxPoolFee, products, ipfsDescriptionHash } = poolInitParams; + const { poolId, initialPoolFee, maxPoolFee, products } = poolInitParams; await stakingPool.connect(fixture.stakingProductsSigner).initialize( false, // isPrivatePool initialPoolFee, maxPoolFee, poolId, - ipfsDescriptionHash, ); await tokenController.setStakingPoolManager(poolId, manager.address); @@ -196,24 +194,18 @@ describe('depositTo', function () { const { amount, tokenId, destination } = depositToFixture; const { firstActiveTrancheId } = await getTranches(DEFAULT_PERIOD, DEFAULT_GRACE_PERIOD); - const newStakeShares = await estimateStakeShares({ amount, stakingPool }); + const newShares = await calculateStakeShares(stakingPool, amount); const expectedTokenId = 1; const tx = await stakingPool.connect(user).depositTo(amount, firstActiveTrancheId, tokenId, destination); await expect(tx).to.emit(stakingNFT, 'Transfer').withArgs(AddressZero, user.address, expectedTokenId); const deposit = await stakingPool.deposits(expectedTokenId, firstActiveTrancheId); - const newRewardShares = await getNewRewardShares({ - stakingPool, - initialStakeShares: 0, - stakeSharesIncrease: deposit.stakeShares, - initialTrancheId: firstActiveTrancheId, - newTrancheId: firstActiveTrancheId, - }); + expect(deposit.pendingRewards).to.equal(0); expect(deposit.lastAccNxmPerRewardShare).to.equal(0); - expect(deposit.stakeShares).to.equal(newStakeShares); - expect(deposit.rewardsShares).to.equal(newRewardShares); + expect(deposit.stakeShares).to.equal(newShares); + expect(deposit.rewardsShares).to.equal(newShares); }); it('register deposit to an existing nft', async function () { @@ -231,23 +223,17 @@ describe('depositTo', function () { await expect(tx).to.emit(stakingNFT, 'Transfer').withArgs(AddressZero, user.address, expectedTokenId); const firstDepositData = await stakingPool.deposits(expectedTokenId, firstActiveTrancheId); - const newStakeShares = await estimateStakeShares({ amount, stakingPool }); + const newShares = await calculateStakeShares(stakingPool, amount); // deposit to the same tokenId await stakingPool.connect(user).depositTo(amount, firstActiveTrancheId, expectedTokenId, destination); const updatedDepositData = await stakingPool.deposits(expectedTokenId, firstActiveTrancheId); - const newRewardShares = await getNewRewardShares({ - stakingPool, - initialStakeShares: firstDepositData.stakeShares, - stakeSharesIncrease: newStakeShares, - initialTrancheId: firstActiveTrancheId, - newTrancheId: firstActiveTrancheId, - }); + expect(updatedDepositData.pendingRewards).to.equal(0); expect(updatedDepositData.lastAccNxmPerRewardShare).to.equal(0); - expect(updatedDepositData.stakeShares).to.equal(firstDepositData.stakeShares.add(newStakeShares)); - expect(updatedDepositData.rewardsShares).to.equal(firstDepositData.rewardsShares.add(newRewardShares)); + expect(updatedDepositData.stakeShares).to.equal(firstDepositData.stakeShares.add(newShares)); + expect(updatedDepositData.rewardsShares).to.equal(firstDepositData.rewardsShares.add(newShares)); }); it('reverts deposit to an existing nft that msg.sender is not an owner of / approved for', async function () { @@ -578,23 +564,15 @@ describe('depositTo', function () { const trancheId = tranches[tokenId - 1]; const deposit = await stakingPool.deposits(tokenId, trancheId); - const newRewardShares = await getNewRewardShares({ - stakingPool, - initialStakeShares: totalStakeShares, - stakeSharesIncrease: deposit.stakeShares, - initialTrancheId: trancheId, - newTrancheId: trancheId, - }); - - const newStakeShares = + const newShares = tokenId === 1 ? Math.sqrt(amount) // first deposit uses sqrt(amount) : amount.mul(totalStakeShares).div(stakedAmount); expect(deposit.pendingRewards).to.equal(0); expect(deposit.lastAccNxmPerRewardShare).to.equal(0); - expect(deposit.stakeShares).to.equal(newStakeShares); - expect(deposit.rewardsShares).to.be.approximately(newRewardShares.toNumber(), 1); + expect(deposit.stakeShares).to.equal(newShares); + expect(deposit.rewardsShares).to.equal(newShares); const managerDeposit = await stakingPool.deposits(managerDepositId, trancheId); diff --git a/test/unit/StakingPool/extendDeposit.js b/test/unit/StakingPool/extendDeposit.js index 0b3bc71a02..f6526f0bfd 100644 --- a/test/unit/StakingPool/extendDeposit.js +++ b/test/unit/StakingPool/extendDeposit.js @@ -1,6 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { getTranches, getNewRewardShares, TRANCHE_DURATION, generateRewards, setTime } = require('./helpers'); +const { getTranches, TRANCHE_DURATION, generateRewards, setTime } = require('./helpers'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); const { increaseTime } = require('../utils').evm; @@ -27,7 +27,6 @@ const poolInitParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [productParams], - ipfsDescriptionHash: 'Description Hash', }; const depositNftId = 1; @@ -39,10 +38,8 @@ async function extendDepositSetup() { const [user] = fixture.accounts.members; const manager = fixture.accounts.defaultSender; - const { poolId, initialPoolFee, maxPoolFee, products, ipfsDescriptionHash } = poolInitParams; - await stakingPool - .connect(fixture.stakingProductsSigner) - .initialize(false, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + const { poolId, initialPoolFee, maxPoolFee, products } = poolInitParams; + await stakingPool.connect(fixture.stakingProductsSigner).initialize(false, initialPoolFee, maxPoolFee, poolId); await tokenController.setStakingPoolManager(poolId, manager.address); await stakingProducts.connect(fixture.stakingProductsSigner).setInitialProducts(poolId, products); @@ -249,13 +246,9 @@ describe('extendDeposit', function () { const newTrancheDeposit = await stakingPool.deposits(depositNftId, maxTranche); const accNxmPerRewardsShare = await stakingPool.getAccNxmPerRewardsShare(); - const newRewardsIncrease = await getNewRewardShares({ - stakingPool, - initialStakeShares: initialDeposit.stakeShares, - stakeSharesIncrease: 0, - initialTrancheId: firstActiveTrancheId, - newTrancheId: maxTranche, - }); + const expectedPendingRewards = initialDeposit.rewardsShares + .mul(newTrancheDeposit.lastAccNxmPerRewardShare.sub(initialDeposit.lastAccNxmPerRewardShare)) + .div(parseEther('1')); expect(updatedInitialDeposit.stakeShares).to.equal(0); expect(updatedInitialDeposit.rewardsShares).to.equal(0); @@ -263,12 +256,8 @@ describe('extendDeposit', function () { expect(updatedInitialDeposit.lastAccNxmPerRewardShare).to.equal(0); expect(newTrancheDeposit.stakeShares).to.equal(initialDeposit.stakeShares); - expect(newTrancheDeposit.rewardsShares).to.equal(initialDeposit.rewardsShares.add(newRewardsIncrease)); - expect(newTrancheDeposit.pendingRewards).to.equal( - initialDeposit.rewardsShares - .mul(newTrancheDeposit.lastAccNxmPerRewardShare.sub(initialDeposit.lastAccNxmPerRewardShare)) - .div(parseEther('1')), - ); + expect(newTrancheDeposit.rewardsShares).to.equal(initialDeposit.rewardsShares); + expect(newTrancheDeposit.pendingRewards).to.equal(expectedPendingRewards); expect(newTrancheDeposit.lastAccNxmPerRewardShare).to.equal(accNxmPerRewardsShare); }); @@ -291,23 +280,15 @@ describe('extendDeposit', function () { const updatedDeposit = await stakingPool.deposits(depositNftId, maxTranche); const accNxmPerRewardsShare = await stakingPool.getAccNxmPerRewardsShare(); - const newRewardsIncrease = await getNewRewardShares({ - stakingPool, - initialStakeShares: initialDeposit.stakeShares, - stakeSharesIncrease: updatedDeposit.stakeShares.sub(initialDeposit.stakeShares), - initialTrancheId: firstActiveTrancheId, - newTrancheId: maxTranche, - }); - - expect(updatedDeposit.stakeShares).to.equal( - initialDeposit.stakeShares.add(topUpAmount.mul(stakeSharesSupply).div(activeStake)), - ); - expect(updatedDeposit.rewardsShares).to.equal(initialDeposit.rewardsShares.add(newRewardsIncrease)); - expect(updatedDeposit.pendingRewards).to.equal( - initialDeposit.rewardsShares - .mul(updatedDeposit.lastAccNxmPerRewardShare.sub(initialDeposit.lastAccNxmPerRewardShare)) - .div(parseEther('1')), - ); + const expectedPendingRewards = initialDeposit.rewardsShares + .mul(updatedDeposit.lastAccNxmPerRewardShare.sub(initialDeposit.lastAccNxmPerRewardShare)) + .div(parseEther('1')); + + const expectedNewShares = topUpAmount.mul(stakeSharesSupply).div(activeStake).add(initialDeposit.stakeShares); + + expect(updatedDeposit.stakeShares).to.equal(expectedNewShares); + expect(updatedDeposit.rewardsShares).to.equal(expectedNewShares); + expect(updatedDeposit.pendingRewards).to.equal(expectedPendingRewards); expect(updatedDeposit.lastAccNxmPerRewardShare).to.equal(accNxmPerRewardsShare); }); @@ -329,17 +310,10 @@ describe('extendDeposit', function () { const updatedDeposit = await stakingPool.deposits(depositNftId, maxTranche); const accNxmPerRewardsShare = await stakingPool.getAccNxmPerRewardsShare(); - const newRewardsShares = await getNewRewardShares({ - stakingPool, - initialStakeShares: 0, - stakeSharesIncrease: updatedDeposit.stakeShares, - initialTrancheId: maxTranche, - newTrancheId: maxTranche, - }); - - const expectedStakeShares = Math.floor(Math.sqrt(amount.add(topUpAmount))); - expect(updatedDeposit.stakeShares).to.equal(expectedStakeShares); - expect(updatedDeposit.rewardsShares).to.equal(newRewardsShares); + const expectedShares = Math.floor(Math.sqrt(amount.add(topUpAmount))); + + expect(updatedDeposit.stakeShares).to.equal(expectedShares); + expect(updatedDeposit.rewardsShares).to.equal(expectedShares); expect(updatedDeposit.pendingRewards).to.equal(0); expect(updatedDeposit.lastAccNxmPerRewardShare).to.equal(accNxmPerRewardsShare); }); @@ -366,11 +340,17 @@ describe('extendDeposit', function () { const updatedInitialTranche = await stakingPool.getTranche(firstActiveTrancheId); const newTranche = await stakingPool.getTranche(maxTranche); + const updatedManagerDeposit = await stakingPool.deposits(managerDepositId, firstActiveTrancheId); + const newTrancheManagerDeposit = await stakingPool.deposits(managerDepositId, maxTranche); + expect(updatedInitialTranche.stakeShares).to.equal(0); - expect(updatedInitialTranche.rewardsShares).to.equal(managerDeposit.rewardsShares); + expect(updatedInitialTranche.rewardsShares).to.equal(0); + expect(updatedManagerDeposit.rewardsShares).to.equal(0); expect(newTranche.stakeShares).to.equal(newTrancheDeposit.stakeShares); - expect(newTranche.rewardsShares).to.equal(newTrancheDeposit.rewardsShares); + expect(newTranche.rewardsShares).to.equal( + newTrancheDeposit.rewardsShares.add(newTrancheManagerDeposit.rewardsShares), + ); }); it('updates global stake and reward shares supply', async function () { @@ -382,28 +362,27 @@ describe('extendDeposit', function () { await generateRewards(stakingPool, fixture.coverSigner); - const initialDeposit = await stakingPool.deposits(depositNftId, firstActiveTrancheId); + const poolFeeDenominator = await stakingPool.POOL_FEE_DENOMINATOR(); + const poolFee = await stakingPool.getPoolFee(); + const activeStake = await stakingPool.getActiveStake(); - const stakeSharesSupply = await stakingPool.getStakeSharesSupply(); - const rewardsSharesSupply = await stakingPool.getRewardsSharesSupply(); + const stakeSharesSupplyBefore = await stakingPool.getStakeSharesSupply(); + const rewardsSharesSupplyBefore = await stakingPool.getRewardsSharesSupply(); const topUpAmount = parseEther('50'); await stakingPool.connect(user).extendDeposit(depositNftId, firstActiveTrancheId, maxTranche, topUpAmount); - const updatedDeposit = await stakingPool.deposits(depositNftId, maxTranche); const stakeSharesSupplyAfter = await stakingPool.getStakeSharesSupply(); const rewardsSharesSupplyAfter = await stakingPool.getRewardsSharesSupply(); - const newRewardsIncrease = await getNewRewardShares({ - stakingPool, - initialStakeShares: initialDeposit.stakeShares, - stakeSharesIncrease: updatedDeposit.stakeShares.sub(initialDeposit.stakeShares), - initialTrancheId: firstActiveTrancheId, - newTrancheId: maxTranche, - }); - - expect(stakeSharesSupplyAfter).to.equal(stakeSharesSupply.add(topUpAmount.mul(stakeSharesSupply).div(activeStake))); - expect(rewardsSharesSupplyAfter).to.equal(rewardsSharesSupply.add(newRewardsIncrease)); + const newShares = topUpAmount.mul(stakeSharesSupplyBefore).div(activeStake); + const newManagerFeeShares = newShares.mul(poolFee).div(poolFeeDenominator.sub(poolFee)); + + const expectedSharesSupplyAfter = stakeSharesSupplyBefore.add(newShares); + const expectedRewardsSharesSupplyAfter = rewardsSharesSupplyBefore.add(newShares).add(newManagerFeeShares); + + expect(stakeSharesSupplyAfter).to.equal(expectedSharesSupplyAfter); + expect(rewardsSharesSupplyAfter).to.equal(expectedRewardsSharesSupplyAfter); }); it('transfers increased deposit amount to token controller', async function () { diff --git a/test/unit/StakingPool/helpers.js b/test/unit/StakingPool/helpers.js index bbd368f604..0bb55c0d20 100644 --- a/test/unit/StakingPool/helpers.js +++ b/test/unit/StakingPool/helpers.js @@ -138,28 +138,15 @@ async function getCurrentBucket() { return Math.floor(lastBlock.timestamp / BUCKET_DURATION); } -async function estimateStakeShares({ amount, stakingPool }) { +async function calculateStakeShares(stakingPool, depositAmount) { const stakeShareSupply = await stakingPool.getStakeSharesSupply(); if (stakeShareSupply.isZero()) { - return Math.sqrt(amount); + return Math.sqrt(depositAmount); } const activeStake = await stakingPool.getActiveStake(); - return amount.mul(stakeShareSupply).div(activeStake); -} - -async function getNewRewardShares(params) { - const { stakingPool, initialStakeShares, stakeSharesIncrease, initialTrancheId, newTrancheId } = params; - const { timestamp: currentTime } = await ethers.provider.getBlock('latest'); - - return stakingPool.calculateNewRewardShares( - initialStakeShares, - stakeSharesIncrease, - initialTrancheId, - newTrancheId, - currentTime, - ); + return depositAmount.mul(stakeShareSupply).div(activeStake); } async function generateRewards( @@ -226,8 +213,7 @@ module.exports = { getTranches, getCurrentTrancheId, getCurrentBucket, - getNewRewardShares, - estimateStakeShares, + calculateStakeShares, generateRewards, calculateStakeAndRewardsWithdrawAmounts, moveTimeToNextBucket, diff --git a/test/unit/StakingPool/initialize.js b/test/unit/StakingPool/initialize.js index 96eb2310b5..98c4ad4882 100644 --- a/test/unit/StakingPool/initialize.js +++ b/test/unit/StakingPool/initialize.js @@ -15,23 +15,20 @@ const initializeParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [product0], - ipfsDescriptionHash: 'Description Hash', }; describe('initialize', function () { it('reverts if cover contract is not the caller', async function () { const fixture = await loadFixture(setup); const { stakingPool, stakingProductsSigner } = fixture; - const { poolId, initialPoolFee, maxPoolFee, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, maxPoolFee, isPrivatePool } = initializeParams; await expect( - stakingPool.initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash), + stakingPool.initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId), ).to.be.revertedWithCustomError(stakingPool, 'OnlyStakingProductsContract'); await expect( - stakingPool - .connect(stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash), + stakingPool.connect(stakingProductsSigner).initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId), ).to.not.be.reverted; }); @@ -39,35 +36,29 @@ describe('initialize', function () { const fixture = await loadFixture(setup); const { stakingPool, stakingProductsSigner } = fixture; - const { poolId, maxPoolFee, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, maxPoolFee, isPrivatePool } = initializeParams; await expect( - stakingPool - .connect(stakingProductsSigner) - .initialize(isPrivatePool, maxPoolFee + 1, maxPoolFee, poolId, ipfsDescriptionHash), + stakingPool.connect(stakingProductsSigner).initialize(isPrivatePool, maxPoolFee + 1, maxPoolFee, poolId), ).to.be.revertedWithCustomError(stakingPool, 'PoolFeeExceedsMax'); }); it('reverts if max pool fee is 100%', async function () { const fixture = await loadFixture(setup); const { stakingPool, stakingProductsSigner } = fixture; - const { poolId, initialPoolFee, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, isPrivatePool } = initializeParams; await expect( - stakingPool - .connect(stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, 100, poolId, ipfsDescriptionHash), + stakingPool.connect(stakingProductsSigner).initialize(isPrivatePool, initialPoolFee, 100, poolId), ).to.be.revertedWithCustomError(stakingPool, 'MaxPoolFeeAbove100'); }); it('correctly initialize pool parameters', async function () { const fixture = await loadFixture(setup); const { stakingPool, stakingProductsSigner } = fixture; - const { poolId, initialPoolFee, maxPoolFee, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, maxPoolFee, isPrivatePool } = initializeParams; - await stakingPool - .connect(stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + await stakingPool.connect(stakingProductsSigner).initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); expect(await stakingPool.getPoolFee()).to.be.equal(initialPoolFee); expect(await stakingPool.getMaxPoolFee()).to.be.equal(maxPoolFee); diff --git a/test/unit/StakingPool/processExpirations.js b/test/unit/StakingPool/processExpirations.js index b5683ff488..8a91a531ee 100644 --- a/test/unit/StakingPool/processExpirations.js +++ b/test/unit/StakingPool/processExpirations.js @@ -38,17 +38,14 @@ const poolInitParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [productParams], - ipfsDescriptionHash: 'Description Hash', }; async function proccessExpirationSetup() { const fixture = await loadFixture(setup); const { stakingPool, stakingProducts } = fixture; - const { poolId, initialPoolFee, maxPoolFee, products, ipfsDescriptionHash } = poolInitParams; + const { poolId, initialPoolFee, maxPoolFee, products } = poolInitParams; - await stakingPool - .connect(fixture.stakingProductsSigner) - .initialize(false, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + await stakingPool.connect(fixture.stakingProductsSigner).initialize(false, initialPoolFee, maxPoolFee, poolId); await stakingProducts.connect(fixture.stakingProductsSigner).setInitialProducts(poolId, products); diff --git a/test/unit/StakingPool/requestAllocation.js b/test/unit/StakingPool/requestAllocation.js index 65e977d85a..33af4222a3 100644 --- a/test/unit/StakingPool/requestAllocation.js +++ b/test/unit/StakingPool/requestAllocation.js @@ -120,13 +120,12 @@ async function requestAllocationSetup() { // Initialize staking pool const poolId = 1; const isPrivatePool = false; - const ipfsDescriptionHash = 'Staking pool 1'; const maxPoolFee = 10; // 10% const initialPoolFee = 7; // 7% await stakingPool .connect(fixture.stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); await stakingProducts .connect(fixture.stakingProductsSigner) diff --git a/test/unit/StakingPool/setPoolFee.js b/test/unit/StakingPool/setPoolFee.js index b0a3b2789b..ab2e7b973a 100644 --- a/test/unit/StakingPool/setPoolFee.js +++ b/test/unit/StakingPool/setPoolFee.js @@ -38,18 +38,17 @@ const initializeParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [product], - ipfsDescriptionHash: 'Description Hash', }; async function setPoolFeeSetup() { const fixture = await loadFixture(setup); const { stakingPool, stakingProducts, tokenController } = fixture; - const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool } = initializeParams; const manager = fixture.accounts.defaultSender; await stakingPool .connect(fixture.stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); await tokenController.setStakingPoolManager(poolId, manager.address); await stakingProducts.connect(fixture.stakingProductsSigner).setInitialProducts(poolId, products); @@ -121,6 +120,7 @@ describe('setPoolFee', function () { const { initialPoolFee } = initializeParams; const newPoolFee = initialPoolFee - 2; + const feeDenominator = await stakingPool.POOL_FEE_DENOMINATOR(); await stakingPool.connect(user).depositTo(depositAmount, trancheId, tokenId, AddressZero); // Generate rewards @@ -131,23 +131,43 @@ describe('setPoolFee', function () { await stakingPool.connect(coverSigner).requestAllocation(coverAmount, previousPremium, allocationRequest); await increaseTime(daysToSeconds(25)); - const depositBefore = await stakingPool.deposits(managerDepositId, trancheId); + const rewardsSharesSupplyBefore = await stakingPool.getRewardsSharesSupply(); + const trancheBefore = await stakingPool.getTranche(trancheId); + const managerDepositBefore = await stakingPool.deposits(managerDepositId, trancheId); + + const expectedFeeSharesBefore = trancheBefore.stakeShares + .mul(initialPoolFee) + .div(feeDenominator.sub(initialPoolFee)); + + const expectedTrancheRewardsSharesBefore = trancheBefore.stakeShares.add(expectedFeeSharesBefore); + expect(trancheBefore.rewardsShares).to.equal(expectedTrancheRewardsSharesBefore); await stakingPool.connect(manager).setPoolFee(newPoolFee); const accNxmPerRewardsShareAfter = await stakingPool.getAccNxmPerRewardsShare(); - const depositAfter = await stakingPool.deposits(managerDepositId, trancheId); + const rewardsSharesSupplyAfter = await stakingPool.getRewardsSharesSupply(); + const trancheAfter = await stakingPool.getTranche(trancheId); + + const expectedFeeSharesAfter = trancheAfter.stakeShares.mul(newPoolFee).div(feeDenominator.sub(newPoolFee)); + const expectedTrancheRewardsSharesAfter = trancheAfter.stakeShares.add(expectedFeeSharesAfter); - const expectedLastAccNxmPerRewardShare = accNxmPerRewardsShareAfter.sub(depositBefore.lastAccNxmPerRewardShare); - expect(depositAfter.lastAccNxmPerRewardShare).to.equal(expectedLastAccNxmPerRewardShare); + expect(trancheAfter.stakeShares).to.equal(trancheBefore.stakeShares); + expect(trancheAfter.rewardsShares).to.equal(expectedTrancheRewardsSharesAfter); - const expectedPendingRewards = depositAfter.lastAccNxmPerRewardShare - .mul(depositBefore.rewardsShares) + const managerDepositAfter = await stakingPool.deposits(managerDepositId, trancheId); + expect(managerDepositAfter.lastAccNxmPerRewardShare).to.equal(accNxmPerRewardsShareAfter); + + const expectedPendingRewards = accNxmPerRewardsShareAfter + .mul(managerDepositBefore.rewardsShares) .div(parseEther('1')); - expect(depositAfter.pendingRewards).to.equal(expectedPendingRewards); - const expectedRewardsShares = depositBefore.rewardsShares.mul(newPoolFee).div(initialPoolFee); - expect(depositAfter.rewardsShares).to.equal(expectedRewardsShares); + expect(managerDepositAfter.pendingRewards).to.equal(expectedPendingRewards); + expect(managerDepositAfter.rewardsShares).to.equal(expectedFeeSharesAfter); + + const expectedRewardsShareSupplyAfter = rewardsSharesSupplyBefore + .sub(expectedFeeSharesBefore) + .add(expectedFeeSharesAfter); + expect(rewardsSharesSupplyAfter).to.equal(expectedRewardsShareSupplyAfter); }); it('emits and PoolFeeChanged', async function () { diff --git a/test/unit/StakingPool/setPoolPrivacy.js b/test/unit/StakingPool/setPoolPrivacy.js index 5c8f272f5e..1d38deec1d 100644 --- a/test/unit/StakingPool/setPoolPrivacy.js +++ b/test/unit/StakingPool/setPoolPrivacy.js @@ -15,7 +15,6 @@ const initializeParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [product0], - ipfsDescriptionHash: 'Descrition Hash', }; async function setPoolPrivacySetup() { @@ -23,11 +22,11 @@ async function setPoolPrivacySetup() { const { stakingPool, stakingProducts, tokenController } = fixture; const manager = fixture.accounts.defaultSender; - const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool } = initializeParams; await stakingPool .connect(fixture.stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); await tokenController.setStakingPoolManager(poolId, manager.address); await stakingProducts.connect(fixture.stakingProductsSigner).setInitialProducts(poolId, products); diff --git a/test/unit/StakingPool/setup.js b/test/unit/StakingPool/setup.js index 7cec4647d7..be00b2d809 100644 --- a/test/unit/StakingPool/setup.js +++ b/test/unit/StakingPool/setup.js @@ -30,14 +30,14 @@ async function setup() { AddressZero, ]); - const stakingPool = await ethers.deployContract('StakingPool', [ - stakingNFT.address, - nxm.address, - cover.address, - tokenController.address, - master.address, - stakingProducts.address, - ]); + const stakingExtrasLib = await ethers.deployContract('StakingExtrasLib'); + await stakingExtrasLib.deployed(); + + const stakingPool = await ethers.deployContract( + 'StakingPool', + [stakingNFT, nxm, cover, tokenController, master, stakingProducts].map(c => c.address), + { libraries: { StakingExtrasLib: stakingExtrasLib.address } }, + ); await nxm.setOperator(tokenController.address); await tokenController.setContractAddresses(cover.address, nxm.address); @@ -72,8 +72,6 @@ async function setup() { } const config = { - REWARD_BONUS_PER_TRANCHE_RATIO: await stakingPool.REWARD_BONUS_PER_TRANCHE_RATIO(), - REWARD_BONUS_PER_TRANCHE_DENOMINATOR: await stakingPool.REWARD_BONUS_PER_TRANCHE_DENOMINATOR(), PRICE_CHANGE_PER_DAY: await stakingProducts.PRICE_CHANGE_PER_DAY(), PRICE_BUMP_RATIO: await stakingProducts.PRICE_BUMP_RATIO(), SURGE_PRICE_RATIO: await stakingProducts.SURGE_PRICE_RATIO(), diff --git a/test/unit/StakingPool/withdraw.js b/test/unit/StakingPool/withdraw.js index 6b66b705bd..d1a38d994e 100644 --- a/test/unit/StakingPool/withdraw.js +++ b/test/unit/StakingPool/withdraw.js @@ -4,8 +4,7 @@ const { expect } = require('chai'); const { increaseTime, mineNextBlock, setNextBlockTime } = require('../utils').evm; const { getTranches, - getNewRewardShares, - estimateStakeShares, + calculateStakeShares, calculateStakeAndRewardsWithdrawAmounts, setTime, generateRewards, @@ -32,7 +31,6 @@ const initializeParams = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% products: [product0], - ipfsDescriptionHash: 'Description Hash', }; const withdrawFixture = { @@ -48,11 +46,11 @@ async function withdrawSetup() { const { stakingPool, stakingProducts, tokenController } = fixture; const manager = fixture.accounts.defaultSender; - const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool, ipfsDescriptionHash } = initializeParams; + const { poolId, initialPoolFee, maxPoolFee, products, isPrivatePool } = initializeParams; await stakingPool .connect(fixture.stakingProductsSigner) - .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId, ipfsDescriptionHash); + .initialize(isPrivatePool, initialPoolFee, maxPoolFee, poolId); await tokenController.setStakingPoolManager(poolId, manager.address); @@ -213,14 +211,7 @@ describe('withdraw', function () { destination, ); - const expectedStakeShares = Math.sqrt(amount); - const expectedRewardShares = await getNewRewardShares({ - stakingPool, - initialStakeShares: 0, - stakeSharesIncrease: expectedStakeShares, - initialTrancheId: firstActiveTrancheId, - newTrancheId: firstActiveTrancheId, - }); + const expectedShares = Math.sqrt(amount); const tcBalanceInitial = await nxm.balanceOf(tokenController.address); await generateRewards(stakingPool, coverSigner); @@ -253,10 +244,10 @@ describe('withdraw', function () { const managerBalanceAfter = await nxm.balanceOf(manager.address); const tcBalanceAfter = await nxm.balanceOf(tokenController.address); - expect(depositBefore.stakeShares).to.be.eq(expectedStakeShares); - expect(depositAfter.stakeShares).to.be.eq(expectedStakeShares); + expect(depositBefore.stakeShares).to.be.eq(expectedShares); + expect(depositAfter.stakeShares).to.be.eq(expectedShares); - expect(depositBefore.rewardsShares).to.be.eq(expectedRewardShares); + expect(depositBefore.rewardsShares).to.be.eq(expectedShares); expect(depositAfter.pendingRewards).to.be.eq(0); const { accNxmPerRewardShareAtExpiry } = await stakingPool.getExpiredTranche(firstActiveTrancheId); @@ -602,7 +593,7 @@ describe('withdraw', function () { it('should emit some event', async function () { const fixture = await loadFixture(withdrawSetup); const { coverSigner, stakingPool } = fixture; - const [user] = fixture.accounts.members; + const [user, otherUser] = fixture.accounts.members; const { amount, tokenId, destination } = withdrawFixture; const TRANCHES_NUMBER = 3; @@ -648,7 +639,7 @@ describe('withdraw', function () { rewards.push(currentReward); } - await expect(stakingPool.connect(user).withdraw(tokenId, withdrawStake, withdrawRewards, trancheIds)) + await expect(stakingPool.connect(otherUser).withdraw(tokenId, withdrawStake, withdrawRewards, trancheIds)) .to.emit(stakingPool, 'Withdraw') .withArgs(user.address, tokenId, trancheIds[0], stakes[0], rewards[0]) .to.emit(stakingPool, 'Withdraw') @@ -693,7 +684,7 @@ describe('withdraw', function () { const user = users[uid]; const amount = depositAmounts[t][uid]; - const stakeShares = await estimateStakeShares({ amount, stakingPool }); + const stakeShares = await calculateStakeShares(stakingPool, amount); userShares[t][uid] = { amount, stakeShares }; await stakingPool.connect(user).depositTo( diff --git a/test/unit/StakingPoolFactory/setup.js b/test/unit/StakingPoolFactory/setup.js index 89c4cc418f..966cf76837 100644 --- a/test/unit/StakingPoolFactory/setup.js +++ b/test/unit/StakingPoolFactory/setup.js @@ -1,9 +1,9 @@ const { ethers } = require('hardhat'); -const { getAccounts } = require('../../utils/accounts'); +const { getAccounts } = require('../utils').accounts; async function setup() { const accounts = await getAccounts(); - const operator = accounts.nonMembers[0]; + const [operator] = accounts.nonMembers; const stakingPoolFactory = await ethers.deployContract('StakingPoolFactory', [operator.address]); diff --git a/test/unit/StakingProducts/createStakingPool.js b/test/unit/StakingProducts/createStakingPool.js index 033464ca95..d61a6e24cf 100644 --- a/test/unit/StakingProducts/createStakingPool.js +++ b/test/unit/StakingProducts/createStakingPool.js @@ -4,21 +4,23 @@ const { expect } = require('chai'); const { keccak256 } = require('ethereum-cryptography/keccak'); const { bytesToHex, hexToBytes } = require('ethereum-cryptography/utils'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + const setup = require('./setup'); + const { AddressZero } = ethers.constants; +const product = { + productId: 200, + weight: 100, + initialPrice: '500', + targetPrice: '500', +}; + const newPoolFixture = { initialPoolFee: 5, // 5% maxPoolFee: 5, // 5% - productInitializationParams: [ - { - productId: 200, - weight: 100, - initialPrice: '500', - targetPrice: '500', - }, - ], - ipfsDescriptionHash: 'Description Hash', + productInitializationParams: [product], + ipfsDescriptionHash: 'staking-pool-ipfs-metadata', }; async function createStakingPoolSetup() { @@ -34,18 +36,10 @@ async function createStakingPoolSetup() { }; const productId = initialProducts.length; + const productParam = { ...coverProductTemplate, initialPriceRatio: coverProductTemplate.initialPriceRatio }; - await coverProducts.setProduct( - { ...coverProductTemplate, initialPriceRatio: coverProductTemplate.initialPriceRatio }, - productId, - ); - await coverProducts.setProductType( - { - claimMethod: 1, - gracePeriod: 7 * 24 * 3600, // 7 days - }, - productId, - ); + await coverProducts.setProduct(productParam, productId); + await coverProducts.setProductType({ claimMethod: 1, gracePeriod: 7 * 24 * 3600 /* = 7 days */ }, productId); return fixture; } @@ -56,7 +50,7 @@ describe('createStakingPool', function () { const { stakingProducts, master } = fixture; const [stakingPoolCreator] = fixture.accounts.members; - const { initialPoolFee, maxPoolFee, productInitializationParams } = newPoolFixture; + const { initialPoolFee, maxPoolFee, productInitializationParams, ipfsDescriptionHash } = newPoolFixture; await master.setEmergencyPause(true); @@ -66,11 +60,28 @@ describe('createStakingPool', function () { initialPoolFee, maxPoolFee, productInitializationParams, - '', // ipfsDescriptionHash + ipfsDescriptionHash, ), ).to.be.revertedWith('System is paused'); }); + it('reverts if ipfsHash is empty', async function () { + const fixture = await loadFixture(createStakingPoolSetup); + const { stakingProducts } = fixture; + const [stakingPoolCreator] = fixture.accounts.members; + + const { initialPoolFee, maxPoolFee, productInitializationParams } = newPoolFixture; + + const createStakingPool = stakingProducts.connect(stakingPoolCreator).createStakingPool( + false, // isPrivatePool, + initialPoolFee, + maxPoolFee, + productInitializationParams, + '', // empty ipfsHash + ); + await expect(createStakingPool).to.be.revertedWithCustomError(stakingProducts, 'IpfsHashRequired'); + }); + it('should create and initialize a new pool minimal beacon proxy pool', async function () { const fixture = await loadFixture(createStakingPoolSetup); const { cover, stakingPoolFactory, stakingProducts, coverProducts } = fixture; @@ -189,7 +200,7 @@ describe('createStakingPool', function () { const { stakingProducts } = fixture; const [nonMember] = fixture.accounts.nonMembers; - const { initialPoolFee, maxPoolFee, productInitializationParams } = newPoolFixture; + const { initialPoolFee, maxPoolFee, productInitializationParams, ipfsDescriptionHash } = newPoolFixture; await expect( stakingProducts.connect(nonMember).createStakingPool( @@ -197,7 +208,7 @@ describe('createStakingPool', function () { initialPoolFee, maxPoolFee, productInitializationParams, - '', // ipfsDescriptionHash + ipfsDescriptionHash, ), ).to.be.revertedWith('Caller is not a member'); }); @@ -227,7 +238,7 @@ describe('createStakingPool', function () { const { stakingProducts, stakingPoolFactory } = fixture; const [stakingPoolCreator] = fixture.accounts.members; - const { initialPoolFee, maxPoolFee, productInitializationParams } = newPoolFixture; + const { initialPoolFee, maxPoolFee, productInitializationParams, ipfsDescriptionHash } = newPoolFixture; const stakingPoolCountBefore = await stakingPoolFactory.stakingPoolCount(); @@ -236,7 +247,7 @@ describe('createStakingPool', function () { initialPoolFee, maxPoolFee, productInitializationParams, - '', // ipfsDescriptionHash + ipfsDescriptionHash, ); const stakingPoolCountAfter = await stakingPoolFactory.stakingPoolCount(); @@ -248,7 +259,7 @@ describe('createStakingPool', function () { const { stakingProducts } = fixture; const { GLOBAL_MIN_PRICE_RATIO } = fixture.config; const [stakingPoolCreator] = fixture.accounts.members; - const { initialPoolFee, maxPoolFee, productInitializationParams } = newPoolFixture; + const { initialPoolFee, maxPoolFee, productInitializationParams, ipfsDescriptionHash } = newPoolFixture; const products = [{ ...productInitializationParams[0], targetPrice: GLOBAL_MIN_PRICE_RATIO - 1 }]; await expect( @@ -257,7 +268,7 @@ describe('createStakingPool', function () { initialPoolFee, maxPoolFee, products, - '', // ipfsDescriptionHash + ipfsDescriptionHash, ), ).to.be.revertedWithCustomError(stakingProducts, 'TargetPriceBelowGlobalMinPriceRatio'); }); diff --git a/test/unit/StakingProducts/helpers.js b/test/unit/StakingProducts/helpers.js index 88d85963fb..97dfaa494e 100644 --- a/test/unit/StakingProducts/helpers.js +++ b/test/unit/StakingProducts/helpers.js @@ -1,7 +1,8 @@ const { expect } = require('chai'); const { ethers } = require('hardhat'); -const { getCurrentTrancheId } = require('../StakingPool/helpers'); -const { setEtherBalance } = require('../../utils/evm'); + +const { setEtherBalance } = require('../utils').evm; + const { BigNumber } = ethers; const { parseEther } = ethers.utils; const { AddressZero } = ethers.constants; @@ -57,6 +58,13 @@ const burnStakeParams = { deallocationAmount: 0, }; +const TRANCHE_DURATION = daysToSeconds(91); + +async function getCurrentTrancheId() { + const { timestamp } = await ethers.provider.getBlock('latest'); + return Math.floor(timestamp / TRANCHE_DURATION); +} + async function verifyProduct(params) { const { coverProducts } = this; let { product, productParams } = params; diff --git a/test/unit/StakingProducts/recalculateEffectiveWeight.js b/test/unit/StakingProducts/recalculateEffectiveWeight.js index 5390013384..cdb7c28f5f 100644 --- a/test/unit/StakingProducts/recalculateEffectiveWeight.js +++ b/test/unit/StakingProducts/recalculateEffectiveWeight.js @@ -1,7 +1,6 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { parseEther } = ethers.utils; -const { Zero, One } = ethers.constants; +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { allocateCapacity, depositTo, @@ -11,14 +10,17 @@ const { burnStakeParams, newProductTemplate, } = require('./helpers'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const setup = require('./setup'); -const { increaseTime, setEtherBalance } = require('../../utils').evm; +const { increaseTime, setEtherBalance } = require('../utils').evm; + +const { parseEther } = ethers.utils; +const { Zero, One } = ethers.constants; const DEFAULT_PRODUCTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; const MAX_TARGET_WEIGHT = 100; const MAX_TOTAL_EFFECTIVE_WEIGHT = 2000; const UINT16_MAX = 65535; + describe('recalculateEffectiveWeight', function () { it('recalculating effective weight should have no effect for products not found in stakingPool', async function () { const fixture = await loadFixture(setup); diff --git a/test/unit/StakingProducts/setPoolMetadata.js b/test/unit/StakingProducts/setPoolMetadata.js new file mode 100644 index 0000000000..379889047d --- /dev/null +++ b/test/unit/StakingProducts/setPoolMetadata.js @@ -0,0 +1,48 @@ +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const setup = require('./setup'); + +describe('setPoolMetadata', function () { + it('reverts if manager is not the caller', async function () { + const fixture = await loadFixture(setup); + const [nonManager] = fixture.accounts.nonMembers; + const stakingProducts = fixture.stakingProducts.connect(nonManager); + + const poolId = 1; + const ipfsHash = 'some-string'; + + await expect(stakingProducts.setPoolMetadata(poolId, ipfsHash)).to.be.revertedWithCustomError( + stakingProducts, + 'OnlyManager', + ); + }); + + it('reverts if ipfsHash is empty', async function () { + const fixture = await loadFixture(setup); + const [nonManager] = fixture.accounts.nonMembers; + const stakingProducts = fixture.stakingProducts.connect(nonManager); + + const poolId = 1; + const emptyIpfsHash = ''; + + const setPoolMetadata = stakingProducts.setPoolMetadata(poolId, emptyIpfsHash); + await expect(setPoolMetadata).to.be.revertedWithCustomError(stakingProducts, 'OnlyManager'); + }); + + it('updates pool metadata', async function () { + const fixture = await loadFixture(setup); + const [manager] = fixture.accounts.members; + const stakingProducts = fixture.stakingProducts.connect(manager); + + const poolId = 1; + const ipfsHash = 'some-string'; + + const initialMetadata = await stakingProducts.getPoolMetadata(poolId); + await stakingProducts.setPoolMetadata(poolId, ipfsHash); + const updatedMetadata = await stakingProducts.getPoolMetadata(poolId); + + expect(updatedMetadata).to.be.not.equal(initialMetadata); + expect(updatedMetadata).to.be.equal(ipfsHash); + }); +}); diff --git a/test/unit/StakingProducts/setProducts.js b/test/unit/StakingProducts/setProducts.js index 02689039ad..f30df705b5 100644 --- a/test/unit/StakingProducts/setProducts.js +++ b/test/unit/StakingProducts/setProducts.js @@ -1,12 +1,14 @@ const { expect } = require('chai'); const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + const { verifyProduct, depositTo, daysToSeconds, newProductTemplate } = require('./helpers'); +const { increaseTime, setEtherBalance } = require('../utils').evm; +const setup = require('./setup'); + const { AddressZero } = ethers.constants; const { parseEther } = ethers.utils; const { BigNumber } = ethers; -const { increaseTime, setEtherBalance } = require('../../utils/evm'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const setup = require('./setup'); const poolId = 1; @@ -50,7 +52,10 @@ describe('setProducts unit tests', function () { const [manager] = fixture.accounts.members; const product = { ...newProductTemplate }; - await expect(stakingProducts.connect(manager).setProducts(324985304958, [product])).to.be.revertedWithoutReason(); + await expect(stakingProducts.connect(manager).setProducts(324985304958, [product])).to.be.revertedWithCustomError( + stakingProducts, + 'OnlyManager', + ); }); it('should set products and store values correctly', async function () { diff --git a/test/unit/StakingProducts/setup.js b/test/unit/StakingProducts/setup.js index 508bc5a181..1a90e2c126 100644 --- a/test/unit/StakingProducts/setup.js +++ b/test/unit/StakingProducts/setup.js @@ -1,12 +1,11 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { getAccounts } = require('../../utils/accounts'); +const { getAccounts } = require('../utils').accounts; const { setEtherBalance } = require('../utils').evm; const { Role } = require('../utils').constants; const { hex } = require('../utils').helpers; -const { BigNumber } = ethers; const { parseEther, getContractAddress } = ethers.utils; const { AddressZero } = ethers.constants; @@ -45,9 +44,9 @@ async function setup() { const stakingNFT = await ethers.deployContract('SKMockStakingNFT'); const coverProducts = await ethers.deployContract('SPMockCoverProducts'); - const nonce = (await accounts.defaultSender.getTransactionCount()) + 2; - const expectedStakingProductsAddress = getContractAddress({ from: accounts.defaultSender.address, nonce }); - const expectedCoverAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 2 }); + const nonce = await accounts.defaultSender.getTransactionCount(); + const expectedStakingProductsAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 2 }); + const expectedCoverAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 5 }); const coverNFT = await ethers.deployContract('CoverNFT', [ 'CoverNFT', 'CNFT', @@ -62,14 +61,22 @@ async function setup() { ]); expect(stakingProducts.address).to.equal(expectedStakingProductsAddress); - const stakingPoolImplementation = await ethers.deployContract('StakingPool', [ - stakingNFT.address, - nxm.address, - expectedCoverAddress, - tokenController.address, - master.address, - stakingProducts.address, - ]); + const stakingExtrasLib = await ethers.deployContract('StakingExtrasLib'); + await stakingExtrasLib.deployed(); + + const stakingPoolImplementation = await ethers.deployContract( + 'StakingPool', + [ + stakingNFT.address, + nxm.address, + expectedCoverAddress, + tokenController.address, + master.address, + stakingProducts.address, + ], + { libraries: { StakingExtrasLib: stakingExtrasLib.address } }, + ); + const cover = await ethers.deployContract('SPMockCover', [ coverNFT.address, stakingNFT.address, @@ -111,31 +118,33 @@ async function setup() { await master.enrollInternal(contract.address); } - let i = 0; + // setup a staking pool + const [member] = accounts.members; + + const [poolId, stakingPoolAddress] = await stakingProducts + .connect(member) + .callStatic.createStakingPool(false, 5, 5, [], 'ipfs hash'); + + await stakingProducts.connect(member).createStakingPool(false, 5, 5, [], 'ipfs hash'); + await tokenController.setStakingPoolManager(poolId, member.address); + + const stakingPool = await ethers.getContractAt('StakingPool', stakingPoolAddress); + + // set initial products const initialProducts = Array(200) .fill('') - .map(() => ({ ...initialProductTemplate, productId: i++ })); + .map((_, productId) => ({ ...initialProductTemplate, productId })); + // Add products to cover contract await Promise.all( initialProducts.map(async ({ productId, initialPrice: initialPriceRatio }) => { await coverProducts.setProduct({ ...coverProductTemplate, initialPriceRatio }, productId); await coverProducts.setProductType(ProductTypeFixture, productId); - await coverProducts.setPoolAllowed(productId, 1 /* poolID */, true); + await coverProducts.setPoolAllowed(productId, poolId, true); }), ); - const ret = await stakingProducts - .connect(accounts.members[0]) - .callStatic.createStakingPool(false, 5, 5, [], 'ipfs hash'); - - await stakingProducts.connect(accounts.members[0]).createStakingPool(false, 5, 5, [], 'ipfs hash'); - - const stakingPool = await ethers.getContractAt('StakingPool', ret[1]); - tokenController.setStakingPoolManager(1 /* poolID */, accounts.members[0].address); - const config = { - REWARD_BONUS_PER_TRANCHE_RATIO: await stakingPool.REWARD_BONUS_PER_TRANCHE_RATIO(), - REWARD_BONUS_PER_TRANCHE_DENOMINATOR: await stakingPool.REWARD_BONUS_PER_TRANCHE_DENOMINATOR(), PRICE_CHANGE_PER_DAY: await stakingProducts.PRICE_CHANGE_PER_DAY(), PRICE_BUMP_RATIO: await stakingProducts.PRICE_BUMP_RATIO(), SURGE_PRICE_RATIO: await stakingProducts.SURGE_PRICE_RATIO(), @@ -159,8 +168,6 @@ async function setup() { const coverSigner = await ethers.getImpersonatedSigner(cover.address); await setEtherBalance(coverSigner.address, ethers.utils.parseEther('1')); - const poolId = BigNumber.from(await stakingPool.getPoolId()); - return { accounts, coverSigner, diff --git a/test/unit/StakingViewer/getPool.js b/test/unit/StakingViewer/getPool.js new file mode 100644 index 0000000000..5057ae90c4 --- /dev/null +++ b/test/unit/StakingViewer/getPool.js @@ -0,0 +1,23 @@ +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { expect } = require('chai'); + +const { setup } = require('./setup'); + +describe('getPool', function () { + it('should retrieve pool info for the given poolId', async function () { + const fixture = await loadFixture(setup); + const { stakingPool, stakingViewer } = fixture.contracts; + const { poolId } = fixture.stakingPool; + + const poolInfo = await stakingViewer.getPool(poolId); + + expect(poolInfo.poolId.toString()).to.equal(poolId.toString()); + expect(poolInfo.isPrivatePool).to.equal(await stakingPool.isPrivatePool()); + expect(poolInfo.manager).to.equal(await stakingPool.manager()); + expect(poolInfo.poolFee).to.equal(await stakingPool.getPoolFee()); + expect(poolInfo.maxPoolFee).to.equal(await stakingPool.getMaxPoolFee()); + expect(poolInfo.activeStake).to.equal(await stakingPool.getActiveStake()); + expect(poolInfo.currentAPY.toString()).to.equal('0'); + expect(poolInfo.metadataIpfsHash).to.equal('ipfs hash'); + }); +}); diff --git a/test/unit/StakingViewer/setup.js b/test/unit/StakingViewer/setup.js index e2b4fec5fc..579743f480 100644 --- a/test/unit/StakingViewer/setup.js +++ b/test/unit/StakingViewer/setup.js @@ -26,9 +26,11 @@ async function setup() { const stakingNFT = await ethers.deployContract('SKMockStakingNFT'); const coverProducts = await ethers.deployContract('SPMockCoverProducts'); - const nonce = (await accounts.defaultSender.getTransactionCount()) + 2; - const expectedStakingProductsAddress = getContractAddress({ from: accounts.defaultSender.address, nonce }); - const expectedCoverAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 2 }); + + const nonce = await accounts.defaultSender.getTransactionCount(); + const expectedStakingProductsAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 2 }); + const expectedCoverAddress = getContractAddress({ from: accounts.defaultSender.address, nonce: nonce + 5 }); + const coverNFT = await ethers.deployContract('CoverNFT', [ 'CoverNFT', 'CNFT', @@ -43,14 +45,21 @@ async function setup() { ]); expect(stakingProducts.address).to.equal(expectedStakingProductsAddress); - const stakingPoolImplementation = await ethers.deployContract('StakingPool', [ - stakingNFT.address, - nxm.address, - expectedCoverAddress, - tokenController.address, - master.address, - stakingProducts.address, - ]); + const stakingExtrasLib = await ethers.deployContract('StakingExtrasLib'); + await stakingExtrasLib.deployed(); + + const stakingPoolImplementation = await ethers.deployContract( + 'StakingPool', + [ + stakingNFT.address, + nxm.address, + expectedCoverAddress, + tokenController.address, + master.address, + stakingProducts.address, + ], + { libraries: { StakingExtrasLib: stakingExtrasLib.address } }, + ); const cover = await ethers.deployContract('Cover', [ coverNFT.address, diff --git a/test/unit/TokenController/setup.js b/test/unit/TokenController/setup.js index 4c95f44346..e95b2100c2 100644 --- a/test/unit/TokenController/setup.js +++ b/test/unit/TokenController/setup.js @@ -10,18 +10,20 @@ async function setup() { const { internalContracts, members } = accounts; const internal = internalContracts[0]; - const pooledStaking = await ethers.deployContract('TCMockPooledStaking'); - const stakingPoolFactory = await ethers.deployContract('StakingPoolFactory', [accounts.defaultSender.address]); + const stakingNFT = await ethers.deployContract('TCMockStakingNFT'); const nxm = await ethers.deployContract('NXMTokenMock'); - const tokenController = await ethers.deployContract('TokenController', [ + const tokenController = await ethers.deployContract('DisposableTokenController', [ '0x0000000000000000000000000000000000000000', '0x0000000000000000000000000000000000000000', stakingPoolFactory.address, nxm.address, + stakingNFT.address, ]); + const pooledStaking = await ethers.deployContract('TCMockPooledStaking', [nxm.address]); + await nxm.addToWhiteList(tokenController.address); const master = await ethers.deployContract('MasterMock'); diff --git a/test/unit/TokenController/withdrawPendingRewards.js b/test/unit/TokenController/withdrawPendingRewards.js deleted file mode 100644 index 506164c8d8..0000000000 --- a/test/unit/TokenController/withdrawPendingRewards.js +++ /dev/null @@ -1,104 +0,0 @@ -const { ethers } = require('hardhat'); -const { expect } = require('chai'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); -const setup = require('./setup'); - -const { AddressZero } = ethers.constants; -const { parseEther } = ethers.utils; - -describe('withdrawPendingRewards', function () { - it('reverts if system is paused', async function () { - const fixture = await loadFixture(setup); - const { tokenController, master } = fixture.contracts; - const [member] = fixture.accounts.members; - - await master.setEmergencyPause(true); - - await expect( - tokenController.connect(member).withdrawPendingRewards(member.address, false, false, 0, []), - ).to.be.revertedWith('System is paused'); - }); - - it('withdraws assessment rewards when fromAssessment param is true', async function () { - const fixture = await loadFixture(setup); - const { tokenController, assessment } = fixture.contracts; - const [member] = fixture.accounts.members; - - const forUser = member.address; - const batchSize = 1; - - // call with fromAssessment = false - await tokenController.connect(member).withdrawPendingRewards(forUser, false, false, batchSize, []); - - { - const calledWithStaker = await assessment.withdrawRewardsLastCalledWithStaker(); - const calledWithBatchSize = await assessment.withdrawRewardsLastCalledWithBatchSize(); - - expect(calledWithStaker).to.equal(AddressZero); - expect(calledWithBatchSize).to.equal(0); - } - - // call with fromAssessment = true - await tokenController.connect(member).withdrawPendingRewards(forUser, false, true, batchSize, []); - - { - const calledWithStaker = await assessment.withdrawRewardsLastCalledWithStaker(); - const calledWithBatchSize = await assessment.withdrawRewardsLastCalledWithBatchSize(); - - expect(calledWithStaker).to.equal(forUser); - expect(calledWithBatchSize).to.equal(batchSize); - } - }); - - it('withdraws governance rewards when fromGovernance param is true', async function () { - const fixture = await loadFixture(setup); - const { tokenController, governance, nxm } = fixture.contracts; - const [member] = fixture.accounts.members; - - const forUser = member.address; - const batchSize = 1; - - const governanceRewards = parseEther('10'); - await governance.setUnclaimedGovernanceRewards(forUser, governanceRewards); - - const initialBalance = await nxm.balanceOf(forUser); - - // call with fromGovernance = false - await tokenController.withdrawPendingRewards(forUser, false, false, batchSize, []); - - { - const balance = await nxm.balanceOf(forUser); - const { memberAddress, maxRecords } = await governance.claimRewardLastCalledWith(); - - expect(balance).to.equal(initialBalance); - expect(memberAddress).to.equal(AddressZero); - expect(maxRecords).to.equal(0); - } - - // call with fromGovernance = true - await tokenController.withdrawPendingRewards(forUser, true, false, batchSize, []); - - { - const balance = await nxm.balanceOf(forUser); - const { memberAddress, maxRecords } = await governance.claimRewardLastCalledWith(); - - expect(balance).to.equal(initialBalance.add(governanceRewards)); - expect(memberAddress).to.equal(forUser); - expect(maxRecords).to.equal(batchSize); - } - }); - - it('reverts if no withdrawable governance rewards', async function () { - const fixture = await loadFixture(setup); - const { tokenController } = fixture.contracts; - const [member] = fixture.accounts.members; - - const forUser = member.address; - const batchSize = 1; - - // call with fromGovernance = false - await expect(tokenController.withdrawPendingRewards(forUser, true, false, batchSize, [])).to.be.revertedWith( - 'TokenController: No withdrawable governance rewards', - ); - }); -});