diff --git a/contracts/RewardPool/IRewardPool.sol b/contracts/RewardPool/IRewardPool.sol index eadcfafc..69cc7f7e 100644 --- a/contracts/RewardPool/IRewardPool.sol +++ b/contracts/RewardPool/IRewardPool.sol @@ -136,6 +136,20 @@ interface IRewardPool { uint256 currentEpochId ) external returns (uint256 penalty, uint256 fullReward); + /** + * @notice Swap a vesting postion from one validator to another + * @param oldValidator The address of the validator to swap from + * @param newValidator The address of the delegator to swap to + * @param delegator The address of the delegator + * @return amount The swapped amount + */ + function onSwapPosition( + address oldValidator, + address newValidator, + address delegator, + uint256 currentEpochId + ) external returns (uint256 amount); + /** * @notice Claims delegator rewards for sender. * @param validator Validator to claim from @@ -252,9 +266,16 @@ interface IRewardPool { function totalDelegationOf(address validator) external view returns (uint256); /** - * @dev Should be called only by the Governance. * @notice Changes the minDelegationAmount + * @dev Should be called only by the Governance. * @param newMinDelegation New minimum delegation amount */ function changeMinDelegation(uint256 newMinDelegation) external; + + /** + * @notice Modifies the balance changes threshold for vested positions + * @dev Should be called only by the Governance. + * @param newBalanceChangeThreshold The number of allowed changes of the balance + */ + function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external; } diff --git a/contracts/RewardPool/libs/DelegationPoolLib.sol b/contracts/RewardPool/libs/DelegationPoolLib.sol index 13f71a78..23d5ae2d 100644 --- a/contracts/RewardPool/libs/DelegationPoolLib.sol +++ b/contracts/RewardPool/libs/DelegationPoolLib.sol @@ -131,8 +131,11 @@ library DelegationPoolLib { uint256 balance, int256 correction ) internal view returns (uint256) { - if (pool.claimedRewards[account] >= rewardsEarned(rps, balance, correction)) return 0; - return rewardsEarned(rps, balance, correction) - pool.claimedRewards[account]; + uint256 _rewardsEarned = rewardsEarned(rps, balance, correction); + uint256 claimedRewards = pool.claimedRewards[account]; + if (claimedRewards >= _rewardsEarned) return 0; + + return _rewardsEarned - claimedRewards; } function claimRewards( diff --git a/contracts/RewardPool/modules/DelegationRewards.sol b/contracts/RewardPool/modules/DelegationRewards.sol index 2375933c..f8cd5ac5 100644 --- a/contracts/RewardPool/modules/DelegationRewards.sol +++ b/contracts/RewardPool/modules/DelegationRewards.sol @@ -7,7 +7,6 @@ import "./RewardsWithdrawal.sol"; import "./../RewardPoolBase.sol"; import "./../libs/DelegationPoolLib.sol"; import "./../libs/VestingPositionLib.sol"; - import "./../../common/Errors.sol"; abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawal { @@ -32,6 +31,7 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa function __DelegationRewards_init_unchained(uint256 newMinDelegation) internal onlyInitializing { _changeMinDelegation(newMinDelegation); + balanceChangeThreshold = 32; } // _______________ External functions _______________ @@ -226,7 +226,7 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa }); // keep the change in the delegation pool params per account - _addNewDelegationPoolParam( + _saveAccountParamsChange( validator, delegator, DelegationPoolParams({ @@ -291,9 +291,15 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa delete delegationPoolParamsHistory[validator][delegator]; } else { // keep the change in the account pool params - uint256 balance = delegation.balanceOf(delegator); - int256 correction = delegation.correctionOf(delegator); - _onAccountParamsChange(validator, delegator, balance, correction, currentEpochId); + _saveAccountParamsChange( + validator, + delegator, + DelegationPoolParams({ + balance: delegation.balanceOf(delegator), + correction: delegation.correctionOf(delegator), + epochNum: currentEpochId + }) + ); } } @@ -305,8 +311,59 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa /** * @inheritdoc IRewardPool */ - function claimDelegatorReward(address validator) public { - _claimDelegatorReward(validator, msg.sender); + function onSwapPosition( + address oldValidator, + address newValidator, + address delegator, + uint256 currentEpochId + ) external onlyValidatorSet returns (uint256 amount) { + VestingPosition memory oldPosition = delegationPositions[oldValidator][delegator]; + // ensure that the old position is active in order to continue the swap + if (!oldPosition.isActive()) { + revert DelegateRequirement({src: "vesting", msg: "OLD_POSITION_INACTIVE"}); + } + + // ensure that the new position is available + if (!isPositionAvailable(newValidator, delegator)) { + revert DelegateRequirement({src: "vesting", msg: "NEW_POSITION_UNAVAILABLE"}); + } + + // update the old delegation position + DelegationPool storage oldDelegation = delegationPools[oldValidator]; + amount = oldDelegation.balanceOf(delegator); + oldDelegation.withdraw(delegator, amount); + + int256 correction = oldDelegation.correctionOf(delegator); + _saveAccountParamsChange( + oldValidator, + delegator, + DelegationPoolParams({balance: 0, correction: correction, epochNum: currentEpochId}) + ); + + DelegationPool storage newDelegation = delegationPools[newValidator]; + // deposit the old amount to the new position + newDelegation.deposit(delegator, amount); + + // transfer the old position parameters to the new one + delegationPositions[newValidator][delegator] = VestingPosition({ + duration: oldPosition.duration, + start: oldPosition.start, + end: oldPosition.end, + base: oldPosition.base, + vestBonus: oldPosition.vestBonus, + rsiBonus: oldPosition.rsiBonus + }); + + // keep the change in the new delegation pool params + _saveAccountParamsChange( + newValidator, + delegator, + DelegationPoolParams({ + balance: amount, + correction: newDelegation.correctionOf(delegator), + epochNum: currentEpochId + }) + ); } /** @@ -378,7 +435,17 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa _changeMinDelegation(newMinDelegation); } + function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external onlyRole(DEFAULT_ADMIN_ROLE) { + balanceChangeThreshold = newBalanceChangeThreshold; + } + // _______________ Public functions _______________ + /** + * @inheritdoc IRewardPool + */ + function claimDelegatorReward(address validator) public { + _claimDelegatorReward(validator, msg.sender); + } /** * @inheritdoc IRewardPool @@ -392,6 +459,8 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa /** * @notice Checks if balance change was already made in the current epoch * @param validator Validator to delegate to + * @param delegator Delegator that has delegated + * @param currentEpochNum Current epoch number */ function isBalanceChangeMade( address validator, @@ -411,6 +480,37 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa return false; } + // TODO: Consider deleting it as we shouldn't be getting into that case + /** + * @notice Checks if the balance changes exceeds the threshold + * @param validator Validator to delegate to + * @param delegator Delegator that has delegated + */ + function isBalanceChangeThresholdExceeded(address validator, address delegator) public view returns (bool) { + return delegationPoolParamsHistory[validator][delegator].length > balanceChangeThreshold; + } + + /** + * @notice Check if the new position that the user wants to swap to is available for the swap + * @dev Available positions one that is not active, not maturing and doesn't have any left balance or rewards + * @param newValidator The address of the new validator + * @param delegator The address of the delegator + */ + function isPositionAvailable(address newValidator, address delegator) public view returns (bool) { + VestingPosition memory newPosition = delegationPositions[newValidator][delegator]; + if (newPosition.isActive() || newPosition.isMaturing()) { + return false; + } + + DelegationPool storage newDelegation = delegationPools[newValidator]; + uint256 balance = newDelegation.balanceOf(delegator); + if (balance != 0 || getRawDelegatorReward(newValidator, delegator) > 0) { + return false; + } + + return true; + } + // _______________ Private functions _______________ function _changeMinDelegation(uint256 newMinDelegation) private { @@ -487,9 +587,15 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa // keep the change in the account pool params uint256 balance = delegation.balanceOf(delegator); - int256 correction = delegation.correctionOf(delegator); - - _onAccountParamsChange(validator, delegator, balance, correction, currentEpochId); + _saveAccountParamsChange( + validator, + delegator, + DelegationPoolParams({ + balance: balance, + correction: delegation.correctionOf(delegator), + epochNum: currentEpochId + }) + ); // Modify end period of position, decrease RSI bonus // balance / old balance = increase coefficient // apply increase coefficient to the vesting period to find the increase in the period @@ -518,14 +624,19 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa _withdrawRewards(delegator, reward); } - function _addNewDelegationPoolParam( + function _saveAccountParamsChange( address validator, address delegator, DelegationPoolParams memory params ) private { if (isBalanceChangeMade(validator, delegator, params.epochNum)) { - // Top up can be made only once per epoch - revert StakeRequirement({src: "_addNewDelegationPoolParam", msg: "BALANCE_CHANGE_ALREADY_MADE"}); + // balance can be changed only once per epoch + revert DelegateRequirement({src: "_saveAccountParamsChange", msg: "BALANCE_CHANGE_ALREADY_MADE"}); + } + + if (isBalanceChangeThresholdExceeded(validator, delegator)) { + // maximum amount of balance changes exceeded + revert DelegateRequirement({src: "_saveAccountParamsChange", msg: "BALANCE_CHANGES_EXCEEDED"}); } delegationPoolParamsHistory[validator][delegator].push(params); @@ -546,23 +657,6 @@ abstract contract DelegationRewards is RewardPoolBase, Vesting, RewardsWithdrawa }); } - function _onAccountParamsChange( - address validator, - address delegator, - uint256 balance, - int256 correction, - uint256 currentEpochId - ) private { - if (isBalanceChangeMade(validator, delegator, currentEpochId)) { - // Top up can be made only once on epoch - revert DelegateRequirement({src: "_onAccountParamsChange", msg: "BALANCE_CHANGE_ALREADY_MADE"}); - } - - delegationPoolParamsHistory[validator][delegator].push( - DelegationPoolParams({balance: balance, correction: correction, epochNum: currentEpochId}) - ); - } - function _getAccountParams( address validator, address manager, diff --git a/contracts/RewardPool/modules/Vesting.sol b/contracts/RewardPool/modules/Vesting.sol index 47c10ba4..60b9ea72 100644 --- a/contracts/RewardPool/modules/Vesting.sol +++ b/contracts/RewardPool/modules/Vesting.sol @@ -69,6 +69,12 @@ abstract contract Vesting is APR { */ mapping(address => mapping(address => RewardParams)) public beforeTopUpParams; + /** + * @notice The threshold for the maximum number of allowed balance changes + * @dev We are using this to restrict unlimited changes of the balance (delegationPoolParamsHistory) + */ + uint256 public balanceChangeThreshold; + // _______________ External functions _______________ function isActivePosition(address staker) external view returns (bool) { diff --git a/contracts/ValidatorSet/modules/Delegation/Delegation.sol b/contracts/ValidatorSet/modules/Delegation/Delegation.sol index 0f788f5b..0bb5a224 100644 --- a/contracts/ValidatorSet/modules/Delegation/Delegation.sol +++ b/contracts/ValidatorSet/modules/Delegation/Delegation.sol @@ -33,6 +33,7 @@ abstract contract Delegation is function delegate(address validator) public payable { if (msg.value == 0) revert DelegateRequirement({src: "delegate", msg: "DELEGATING_AMOUNT_ZERO"}); _delegate(validator, msg.sender, msg.value); + LiquidStaking._distributeTokens(msg.sender, msg.value); rewardPool.onDelegate(validator, msg.sender, msg.value); } @@ -42,6 +43,7 @@ abstract contract Delegation is function undelegate(address validator, uint256 amount) external { rewardPool.onUndelegate(validator, msg.sender, amount); _undelegate(validator, msg.sender, amount); + LiquidStaking._collectDelegatorTokens(msg.sender, amount); _registerWithdrawal(msg.sender, amount); } @@ -50,6 +52,7 @@ abstract contract Delegation is */ function delegateWithVesting(address validator, uint256 durationWeeks) external payable onlyManager { _delegate(validator, msg.sender, msg.value); + LiquidStaking._distributeTokens(msg.sender, msg.value); rewardPool.onNewDelegatePosition(validator, msg.sender, durationWeeks, currentEpochId, msg.value); emit PositionOpened(msg.sender, validator, durationWeeks, msg.value); @@ -60,7 +63,7 @@ abstract contract Delegation is */ function topUpDelegatePosition(address validator) external payable onlyManager { _delegate(validator, msg.sender, msg.value); - + LiquidStaking._distributeTokens(msg.sender, msg.value); rewardPool.onTopUpDelegatePosition(validator, msg.sender, currentEpochId, msg.value); emit PositionTopUp(msg.sender, validator, msg.value); @@ -72,6 +75,7 @@ abstract contract Delegation is function undelegateWithVesting(address validator, uint256 amount) external onlyManager { (uint256 penalty, ) = rewardPool.onCutPosition(validator, msg.sender, amount, currentEpochId); _undelegate(validator, msg.sender, amount); + LiquidStaking._collectDelegatorTokens(msg.sender, amount); uint256 amountAfterPenalty = amount - penalty; _burnAmount(penalty); _registerWithdrawal(msg.sender, amountAfterPenalty); @@ -79,12 +83,22 @@ abstract contract Delegation is emit PositionCut(msg.sender, validator, amountAfterPenalty); } + /** + * @inheritdoc IDelegation + */ + function swapVestedPositionValidator(address oldValidator, address newValidator) external onlyManager { + uint256 amount = rewardPool.onSwapPosition(oldValidator, newValidator, msg.sender, currentEpochId); + _undelegate(oldValidator, msg.sender, amount); + _delegate(newValidator, msg.sender, amount); + + emit PositionSwapped(msg.sender, oldValidator, newValidator, amount); + } + // _______________ Private functions _______________ function _delegate(address validator, address delegator, uint256 amount) private onlyActiveValidator(validator) { _increaseAccountBalance(validator, amount); // increase validator power StateSyncer._syncStake(validator, balanceOf(validator)); - LiquidStaking._distributeTokens(delegator, amount); emit Delegated(validator, delegator, amount); } @@ -92,7 +106,6 @@ abstract contract Delegation is function _undelegate(address validator, address delegator, uint256 amount) private { _decreaseAccountBalance(validator, amount); // decrease validator power StateSyncer._syncStake(validator, balanceOf(validator)); - LiquidStaking._collectDelegatorTokens(delegator, amount); emit Undelegated(validator, delegator, amount); } diff --git a/contracts/ValidatorSet/modules/Delegation/IDelegation.sol b/contracts/ValidatorSet/modules/Delegation/IDelegation.sol index 82dceafe..20e6b1be 100644 --- a/contracts/ValidatorSet/modules/Delegation/IDelegation.sol +++ b/contracts/ValidatorSet/modules/Delegation/IDelegation.sol @@ -43,4 +43,12 @@ interface IDelegation { * @param amount Amount to be undelegated */ function undelegateWithVesting(address validator, uint256 amount) external; + + /** + * @notice Move a vested position to another validator. + * Can be called by vesting positions' managers only. + * @param oldValidator Validator to swap from + * @param newValidator Validator to swap to + */ + function swapVestedPositionValidator(address oldValidator, address newValidator) external; } diff --git a/contracts/ValidatorSet/modules/Delegation/IVestedDelegation.sol b/contracts/ValidatorSet/modules/Delegation/IVestedDelegation.sol index cbf6e27f..8fab0318 100644 --- a/contracts/ValidatorSet/modules/Delegation/IVestedDelegation.sol +++ b/contracts/ValidatorSet/modules/Delegation/IVestedDelegation.sol @@ -10,6 +10,7 @@ interface IVestedDelegation { ); event PositionTopUp(address indexed manager, address indexed validator, uint256 amount); event PositionCut(address indexed manager, address indexed validator, uint256 amount); + event PositionSwapped(address indexed manager, address indexed oldValidator, address newValidator, uint256 amount); /// @notice Gets the vesting managers per user address for fast off-chain lookup. function getUserVestManagers(address user) external view returns (address[] memory); diff --git a/contracts/ValidatorSet/modules/Delegation/VestManager.sol b/contracts/ValidatorSet/modules/Delegation/VestManager.sol index 1e5bd2a7..b2ab7a84 100644 --- a/contracts/ValidatorSet/modules/Delegation/VestManager.sol +++ b/contracts/ValidatorSet/modules/Delegation/VestManager.sol @@ -53,6 +53,10 @@ contract VestManager is Initializable, OwnableUpgradeable { IDelegation(delegation).undelegateWithVesting(validator, amount); } + function swapVestedPositionValidator(address oldValidator, address newValidator) external onlyOwner { + IDelegation(delegation).swapVestedPositionValidator(oldValidator, newValidator); + } + function claimVestedPositionReward( address validator, uint256 epochNumber, diff --git a/docs/RewardPool/IRewardPool.md b/docs/RewardPool/IRewardPool.md index cbbfe10b..a4b042d6 100644 --- a/docs/RewardPool/IRewardPool.md +++ b/docs/RewardPool/IRewardPool.md @@ -81,6 +81,22 @@ Returns the total reward that is generated for a position |---|---|---| | reward | uint256 | for the delegator | +### changeBalanceChangeThreshold + +```solidity +function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external nonpayable +``` + +Modifies the balance changes threshold for vested positions + +*Should be called only by the Governance.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newBalanceChangeThreshold | uint256 | The number of allowed changes of the balance | + ### changeMinDelegation ```solidity @@ -404,6 +420,31 @@ Update the reward params for the vested position | amount | uint256 | Amount to stake | | oldBalance | uint256 | Balance before stake | +### onSwapPosition + +```solidity +function onSwapPosition(address oldValidator, address newValidator, address delegator, uint256 currentEpochId) external nonpayable returns (uint256 amount) +``` + +Swap a vesting postion from one validator to another + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | The address of the validator to swap from | +| newValidator | address | The address of the delegator to swap to | +| delegator | address | The address of the delegator | +| currentEpochId | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| amount | uint256 | The swapped amount | + ### onTopUpDelegatePosition ```solidity diff --git a/docs/RewardPool/RewardPool.md b/docs/RewardPool/RewardPool.md index 63cc0557..f4a1fee9 100644 --- a/docs/RewardPool/RewardPool.md +++ b/docs/RewardPool/RewardPool.md @@ -281,6 +281,23 @@ function applyMaxReward(uint256 reward) external view returns (uint256) |---|---|---| | reward | uint256 | undefined | +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### balanceChangeThreshold + +```solidity +function balanceChangeThreshold() external view returns (uint256) +``` + +The threshold for the maximum number of allowed balance changes + + + + #### Returns | Name | Type | Description | @@ -400,6 +417,22 @@ Returns the total reward that is generated for a position |---|---|---| | reward | uint256 | for the delegator | +### changeBalanceChangeThreshold + +```solidity +function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external nonpayable +``` + +Modifies the balance changes threshold for vested positions + +*Should be called only by the Governance.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newBalanceChangeThreshold | uint256 | The number of allowed changes of the balance | + ### changeMinDelegation ```solidity @@ -987,8 +1020,31 @@ Checks if balance change was already made in the current epoch | Name | Type | Description | |---|---|---| | validator | address | Validator to delegate to | -| delegator | address | undefined | -| currentEpochNum | uint256 | undefined | +| delegator | address | Delegator that has delegated | +| currentEpochNum | uint256 | Current epoch number | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + +### isBalanceChangeThresholdExceeded + +```solidity +function isBalanceChangeThresholdExceeded(address validator, address delegator) external view returns (bool) +``` + +Checks if the balance changes exceeds the threshold + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| validator | address | Validator to delegate to | +| delegator | address | Delegator that has delegated | #### Returns @@ -1041,6 +1097,29 @@ function isMaturingPosition(address staker) external view returns (bool) |---|---|---| | _0 | bool | undefined | +### isPositionAvailable + +```solidity +function isPositionAvailable(address newValidator, address delegator) external view returns (bool) +``` + +Check if the new position that the user wants to swap to is available for the swap + +*Available positions one that is not active, not maturing and doesn't have any left balance or rewards* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newValidator | address | The address of the new validator | +| delegator | address | The address of the delegator | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + ### isStakerInVestingCycle ```solidity @@ -1212,6 +1291,31 @@ Update the reward params for the vested position | amount | uint256 | Amount to stake | | oldBalance | uint256 | Balance before stake | +### onSwapPosition + +```solidity +function onSwapPosition(address oldValidator, address newValidator, address delegator, uint256 currentEpochId) external nonpayable returns (uint256 amount) +``` + +Swap a vesting postion from one validator to another + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | The address of the validator to swap from | +| newValidator | address | The address of the delegator to swap to | +| delegator | address | The address of the delegator | +| currentEpochId | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| amount | uint256 | The swapped amount | + ### onTopUpDelegatePosition ```solidity diff --git a/docs/RewardPool/RewardPoolBase.md b/docs/RewardPool/RewardPoolBase.md index 1777db32..03d65cac 100644 --- a/docs/RewardPool/RewardPoolBase.md +++ b/docs/RewardPool/RewardPoolBase.md @@ -81,6 +81,22 @@ Returns the total reward that is generated for a position |---|---|---| | reward | uint256 | for the delegator | +### changeBalanceChangeThreshold + +```solidity +function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external nonpayable +``` + +Modifies the balance changes threshold for vested positions + +*Should be called only by the Governance.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newBalanceChangeThreshold | uint256 | The number of allowed changes of the balance | + ### changeMinDelegation ```solidity @@ -404,6 +420,31 @@ Update the reward params for the vested position | amount | uint256 | Amount to stake | | oldBalance | uint256 | Balance before stake | +### onSwapPosition + +```solidity +function onSwapPosition(address oldValidator, address newValidator, address delegator, uint256 currentEpochId) external nonpayable returns (uint256 amount) +``` + +Swap a vesting postion from one validator to another + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | The address of the validator to swap from | +| newValidator | address | The address of the delegator to swap to | +| delegator | address | The address of the delegator | +| currentEpochId | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| amount | uint256 | The swapped amount | + ### onTopUpDelegatePosition ```solidity diff --git a/docs/RewardPool/modules/DelegationRewards.md b/docs/RewardPool/modules/DelegationRewards.md index 4c307320..55aa33a4 100644 --- a/docs/RewardPool/modules/DelegationRewards.md +++ b/docs/RewardPool/modules/DelegationRewards.md @@ -179,6 +179,23 @@ function applyMaxReward(uint256 reward) external view returns (uint256) |---|---|---| | reward | uint256 | undefined | +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### balanceChangeThreshold + +```solidity +function balanceChangeThreshold() external view returns (uint256) +``` + +The threshold for the maximum number of allowed balance changes + + + + #### Returns | Name | Type | Description | @@ -298,6 +315,22 @@ Returns the total reward that is generated for a position |---|---|---| | reward | uint256 | for the delegator | +### changeBalanceChangeThreshold + +```solidity +function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external nonpayable +``` + +Modifies the balance changes threshold for vested positions + +*Should be called only by the Governance.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newBalanceChangeThreshold | uint256 | The number of allowed changes of the balance | + ### changeMinDelegation ```solidity @@ -839,8 +872,31 @@ Checks if balance change was already made in the current epoch | Name | Type | Description | |---|---|---| | validator | address | Validator to delegate to | -| delegator | address | undefined | -| currentEpochNum | uint256 | undefined | +| delegator | address | Delegator that has delegated | +| currentEpochNum | uint256 | Current epoch number | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + +### isBalanceChangeThresholdExceeded + +```solidity +function isBalanceChangeThresholdExceeded(address validator, address delegator) external view returns (bool) +``` + +Checks if the balance changes exceeds the threshold + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| validator | address | Validator to delegate to | +| delegator | address | Delegator that has delegated | #### Returns @@ -893,6 +949,29 @@ function isMaturingPosition(address staker) external view returns (bool) |---|---|---| | _0 | bool | undefined | +### isPositionAvailable + +```solidity +function isPositionAvailable(address newValidator, address delegator) external view returns (bool) +``` + +Check if the new position that the user wants to swap to is available for the swap + +*Available positions one that is not active, not maturing and doesn't have any left balance or rewards* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newValidator | address | The address of the new validator | +| delegator | address | The address of the delegator | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | bool | undefined | + ### isStakerInVestingCycle ```solidity @@ -1064,6 +1143,31 @@ Update the reward params for the vested position | amount | uint256 | Amount to stake | | oldBalance | uint256 | Balance before stake | +### onSwapPosition + +```solidity +function onSwapPosition(address oldValidator, address newValidator, address delegator, uint256 currentEpochId) external nonpayable returns (uint256 amount) +``` + +Swap a vesting postion from one validator to another + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | The address of the validator to swap from | +| newValidator | address | The address of the delegator to swap to | +| delegator | address | The address of the delegator | +| currentEpochId | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| amount | uint256 | The swapped amount | + ### onTopUpDelegatePosition ```solidity @@ -1580,23 +1684,6 @@ error InvalidRSI() -### StakeRequirement - -```solidity -error StakeRequirement(string src, string msg) -``` - - - - - -#### Parameters - -| Name | Type | Description | -|---|---|---| -| src | string | undefined | -| msg | string | undefined | - ### Unauthorized ```solidity diff --git a/docs/RewardPool/modules/StakingRewards.md b/docs/RewardPool/modules/StakingRewards.md index e19f1e17..a5f2d46e 100644 --- a/docs/RewardPool/modules/StakingRewards.md +++ b/docs/RewardPool/modules/StakingRewards.md @@ -162,6 +162,23 @@ function applyMaxReward(uint256 reward) external view returns (uint256) |---|---|---| | reward | uint256 | undefined | +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### balanceChangeThreshold + +```solidity +function balanceChangeThreshold() external view returns (uint256) +``` + +The threshold for the maximum number of allowed balance changes + + + + #### Returns | Name | Type | Description | @@ -281,6 +298,22 @@ Returns the total reward that is generated for a position |---|---|---| | reward | uint256 | for the delegator | +### changeBalanceChangeThreshold + +```solidity +function changeBalanceChangeThreshold(uint256 newBalanceChangeThreshold) external nonpayable +``` + +Modifies the balance changes threshold for vested positions + +*Should be called only by the Governance.* + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newBalanceChangeThreshold | uint256 | The number of allowed changes of the balance | + ### changeMinDelegation ```solidity @@ -1008,6 +1041,31 @@ Update the reward params for the vested position | amount | uint256 | Amount to stake | | oldBalance | uint256 | Balance before stake | +### onSwapPosition + +```solidity +function onSwapPosition(address oldValidator, address newValidator, address delegator, uint256 currentEpochId) external nonpayable returns (uint256 amount) +``` + +Swap a vesting postion from one validator to another + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | The address of the validator to swap from | +| newValidator | address | The address of the delegator to swap to | +| delegator | address | The address of the delegator | +| currentEpochId | uint256 | undefined | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| amount | uint256 | The swapped amount | + ### onTopUpDelegatePosition ```solidity diff --git a/docs/RewardPool/modules/Vesting.md b/docs/RewardPool/modules/Vesting.md index a33fdec0..e1972161 100644 --- a/docs/RewardPool/modules/Vesting.md +++ b/docs/RewardPool/modules/Vesting.md @@ -162,6 +162,23 @@ function applyMaxReward(uint256 reward) external view returns (uint256) |---|---|---| | reward | uint256 | undefined | +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | uint256 | undefined | + +### balanceChangeThreshold + +```solidity +function balanceChangeThreshold() external view returns (uint256) +``` + +The threshold for the maximum number of allowed balance changes + +*We are using this to restrict unlimited changes of the balance (delegationPoolParamsHistory)* + + #### Returns | Name | Type | Description | diff --git a/docs/ValidatorSet/ValidatorSet.md b/docs/ValidatorSet/ValidatorSet.md index 0e470bd4..6cc51074 100644 --- a/docs/ValidatorSet/ValidatorSet.md +++ b/docs/ValidatorSet/ValidatorSet.md @@ -959,6 +959,23 @@ Stakes sent amount with vesting period. |---|---|---| | durationWeeks | uint256 | Duration of the vesting in weeks. Must be between 1 and 52. | +### swapVestedPositionValidator + +```solidity +function swapVestedPositionValidator(address oldValidator, address newValidator) external nonpayable +``` + +Move a vested position to another validator. Can be called by vesting positions' managers only. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | Validator to swap from | +| newValidator | address | Validator to swap to | + ### topUpDelegatePosition ```solidity @@ -1526,6 +1543,25 @@ event PositionOpened(address indexed manager, address indexed validator, uint256 | weeksDuration `indexed` | uint256 | undefined | | amount | uint256 | undefined | +### PositionSwapped + +```solidity +event PositionSwapped(address indexed manager, address indexed oldValidator, address newValidator, uint256 amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| manager `indexed` | address | undefined | +| oldValidator `indexed` | address | undefined | +| newValidator | address | undefined | +| amount | uint256 | undefined | + ### PositionTopUp ```solidity diff --git a/docs/ValidatorSet/modules/Delegation/Delegation.md b/docs/ValidatorSet/modules/Delegation/Delegation.md index c41700ac..f0b56e95 100644 --- a/docs/ValidatorSet/modules/Delegation/Delegation.md +++ b/docs/ValidatorSet/modules/Delegation/Delegation.md @@ -538,6 +538,23 @@ function stakeBalances(address) external view returns (uint256) |---|---|---| | _0 | uint256 | undefined | +### swapVestedPositionValidator + +```solidity +function swapVestedPositionValidator(address oldValidator, address newValidator) external nonpayable +``` + +Move a vested position to another validator. Can be called by vesting positions' managers only. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | Validator to swap from | +| newValidator | address | Validator to swap to | + ### topUpDelegatePosition ```solidity @@ -988,6 +1005,25 @@ event PositionOpened(address indexed manager, address indexed validator, uint256 | weeksDuration `indexed` | uint256 | undefined | | amount | uint256 | undefined | +### PositionSwapped + +```solidity +event PositionSwapped(address indexed manager, address indexed oldValidator, address newValidator, uint256 amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| manager `indexed` | address | undefined | +| oldValidator `indexed` | address | undefined | +| newValidator | address | undefined | +| amount | uint256 | undefined | + ### PositionTopUp ```solidity diff --git a/docs/ValidatorSet/modules/Delegation/IDelegation.md b/docs/ValidatorSet/modules/Delegation/IDelegation.md index 09c69d20..6f77865d 100644 --- a/docs/ValidatorSet/modules/Delegation/IDelegation.md +++ b/docs/ValidatorSet/modules/Delegation/IDelegation.md @@ -43,6 +43,23 @@ Delegates sent amount to validator. Set vesting position data. Delete old top-up | validator | address | Validator to delegate to | | durationWeeks | uint256 | Duration of the vesting in weeks | +### swapVestedPositionValidator + +```solidity +function swapVestedPositionValidator(address oldValidator, address newValidator) external nonpayable +``` + +Move a vested position to another validator. Can be called by vesting positions' managers only. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | Validator to swap from | +| newValidator | address | Validator to swap to | + ### topUpDelegatePosition ```solidity diff --git a/docs/ValidatorSet/modules/Delegation/IVestedDelegation.md b/docs/ValidatorSet/modules/Delegation/IVestedDelegation.md index d6abd4ae..a10b9b57 100644 --- a/docs/ValidatorSet/modules/Delegation/IVestedDelegation.md +++ b/docs/ValidatorSet/modules/Delegation/IVestedDelegation.md @@ -89,6 +89,25 @@ event PositionOpened(address indexed manager, address indexed validator, uint256 | weeksDuration `indexed` | uint256 | undefined | | amount | uint256 | undefined | +### PositionSwapped + +```solidity +event PositionSwapped(address indexed manager, address indexed oldValidator, address newValidator, uint256 amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| manager `indexed` | address | undefined | +| oldValidator `indexed` | address | undefined | +| newValidator | address | undefined | +| amount | uint256 | undefined | + ### PositionTopUp ```solidity diff --git a/docs/ValidatorSet/modules/Delegation/VestManager.md b/docs/ValidatorSet/modules/Delegation/VestManager.md index 6e83df26..fe2a22f8 100644 --- a/docs/ValidatorSet/modules/Delegation/VestManager.md +++ b/docs/ValidatorSet/modules/Delegation/VestManager.md @@ -141,6 +141,23 @@ The reward pool address |---|---|---| | _0 | address | undefined | +### swapVestedPositionValidator + +```solidity +function swapVestedPositionValidator(address oldValidator, address newValidator) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| oldValidator | address | undefined | +| newValidator | address | undefined | + ### topUpVestedDelegatePosition ```solidity diff --git a/docs/ValidatorSet/modules/Delegation/VestedDelegation.md b/docs/ValidatorSet/modules/Delegation/VestedDelegation.md index 4dfc6846..39399104 100644 --- a/docs/ValidatorSet/modules/Delegation/VestedDelegation.md +++ b/docs/ValidatorSet/modules/Delegation/VestedDelegation.md @@ -206,6 +206,25 @@ event PositionOpened(address indexed manager, address indexed validator, uint256 | weeksDuration `indexed` | uint256 | undefined | | amount | uint256 | undefined | +### PositionSwapped + +```solidity +event PositionSwapped(address indexed manager, address indexed oldValidator, address newValidator, uint256 amount) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| manager `indexed` | address | undefined | +| oldValidator `indexed` | address | undefined | +| newValidator | address | undefined | +| amount | uint256 | undefined | + ### PositionTopUp ```solidity diff --git a/test/RewardPool/RewardPool.test.ts b/test/RewardPool/RewardPool.test.ts index 294657bf..6ffc41d5 100644 --- a/test/RewardPool/RewardPool.test.ts +++ b/test/RewardPool/RewardPool.test.ts @@ -3,7 +3,7 @@ import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; import * as hre from "hardhat"; import { expect } from "chai"; -import { EPOCHS_YEAR, ERRORS, MIN_RSI_BONUS, VESTING_DURATION_WEEKS, WEEK } from "../constants"; +import { DAY, EPOCHS_YEAR, ERRORS, VESTING_DURATION_WEEKS, WEEK } from "../constants"; import { calculateExpectedReward, commitEpoch, @@ -282,16 +282,14 @@ export function RunVestedDelegationRewardsTests(): void { this.minDelegation ); - // pass two weeks ahead - await time.increase(WEEK * 2); - - // Commit epochs so rewards to be distributed + // commit epochs to distribute rewards await commitEpochs( systemValidatorSet, rewardPool, [this.signers.validators[0], validator], - 10, // number of epochs to commit - this.epochSize + 5, // number of epochs to commit + this.epochSize, + DAY * 3 // three days per epoch, so, 3 x 5 = 15 days ahead ); const managerRewards = await getDelegatorPositionReward( @@ -309,15 +307,13 @@ export function RunVestedDelegationRewardsTests(): void { this.fixtures.weeklyVestedDelegationFixture ); - // enter maturing period - await time.increase(WEEK * 1 + 1); - - // Commit epoch so some more rewards are distributed + // commit epoch so some more rewards are distributed await commitEpoch( systemValidatorSet, rewardPool, [this.signers.validators[0], delegatedValidator], - this.epochSize + this.epochSize, + WEEK + 1 ); const managerRewards = await getDelegatorPositionReward( @@ -355,16 +351,14 @@ export function RunVestedDelegationRewardsTests(): void { this.minDelegation ); - // pass two weeks ahead - await time.increase(WEEK * 2); - // Commit epochs so rewards to be distributed await commitEpochs( systemValidatorSet, rewardPool, [this.signers.validators[0], this.signers.validators[1], validator], - 10, // number of epochs to commit - this.epochSize + 5, // number of epochs to commit + this.epochSize, + DAY * 3 // three days per epoch, so, 3 x 5 = 15 days ahead ); const manager1rewards = await rewardPool.calculateTotalPositionReward(validator.address, manager1.address); @@ -394,16 +388,14 @@ export function RunVestedDelegationRewardsTests(): void { this.minDelegation ); - // pass three weeks ahead - await time.increase(WEEK * 3); - // Commit epochs so rewards to be distributed await commitEpochs( systemValidatorSet, rewardPool, [this.signers.validators[0], this.signers.validators[1], validator], - 20, // number of epochs to commit - this.epochSize + 7, // number of epochs to commit + this.epochSize, + DAY * 3 // three days per epoch, so, 3 x 7 = 21 days ahead ); const manager1rewards = await rewardPool.calculateTotalPositionReward(validator.address, manager1.address); @@ -433,16 +425,14 @@ export function RunVestedDelegationRewardsTests(): void { this.minDelegation ); - // pass five weeks ahead - await time.increase(WEEK * 5); - - // Commit epochs so rewards to be distributed + // commit epochs so rewards to be distributed await commitEpochs( systemValidatorSet, rewardPool, [this.signers.validators[0], this.signers.validators[1], validator], - 20, // number of epochs to commit - this.epochSize + 5, // number of epochs to commit + this.epochSize, + WEEK // one week = 1 epoch ); const manager1rewards = await rewardPool.calculateTotalPositionReward(validator.address, manager1.address); @@ -551,7 +541,7 @@ export function RunVestedDelegateClaimTests(): void { .withArgs("vesting", "WRONG_RPS"); }); - it("should properly claim reward when no top-ups and not full reward matured", async function () { + it("should properly claim reward when not fully matured", async function () { const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = await loadFixture(this.fixtures.weeklyVestedDelegationFixture); @@ -567,15 +557,13 @@ export function RunVestedDelegateClaimTests(): void { const maxRSI = await rewardPool.MAX_RSI_BONUS(); const maxReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - // enter the maturing state - await time.increase(WEEK * 1 + 1); - // commit epoch, so more reward is added that must not be claimed now await commitEpoch( systemValidatorSet, rewardPool, [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize + this.epochSize, + WEEK + 1 ); // prepare params for call @@ -595,7 +583,7 @@ export function RunVestedDelegateClaimTests(): void { ); }); - it("should properly claim reward when no top-ups and full reward matured", async function () { + it("should properly claim reward when position fully matured", async function () { const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = await loadFixture(this.fixtures.weeklyVestedDelegationFixture); @@ -611,15 +599,13 @@ export function RunVestedDelegateClaimTests(): void { const maxRSI = await rewardPool.MAX_RSI_BONUS(); const maxReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - // ensure maturing has finished - await time.increase(WEEK * 2 + 1); - - // more rewards to be distributed but with the top-up data + // more rewards to be distributed await commitEpoch( systemValidatorSet, rewardPool, [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize + this.epochSize, + WEEK * 2 + 1 ); const additionalReward = ( @@ -652,466 +638,5 @@ export function RunVestedDelegateClaimTests(): void { [maxFinalReward.sub(expectedFinalReward), expectedFinalReward, maxFinalReward.mul(-1)] ); }); - - it("should properly claim reward when top-ups and not full reward matured", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = - await loadFixture(this.fixtures.weeklyVestedDelegationFixture); - - // calculate base rewards - const baseReward = await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address); - const base = await rewardPool.base(); - const vestBonus = await rewardPool.getVestingBonus(1); - const rsi = await rewardPool.rsi(); - const expectedBaseReward = await calculateExpectedReward(base, vestBonus, rsi, baseReward); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { - value: this.minDelegation, - }); - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - const topUpRewardsTimestamp = await time.latest(); - const position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - const toBeMatured = hre.ethers.BigNumber.from(topUpRewardsTimestamp).sub(position.start); - - // calculate top-up reward - const topUpReward = (await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address)).sub( - baseReward - ); - - // no rsi because top-up is used - const expectedTopUpReward = await calculateExpectedReward(base, vestBonus, MIN_RSI_BONUS, topUpReward); - const expectedReward = expectedBaseReward.add(expectedTopUpReward); - - // calculate max reward - const maxVestBonus = await rewardPool.getVestingBonus(52); - const maxRSI = await rewardPool.MAX_RSI_BONUS(); - const maxBaseReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - const maxTopUpReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, topUpReward); - const maxReward = maxBaseReward.add(maxTopUpReward); - - // enter the maturing state - // two week is the duration + the needed time for the top-up to be matured - await time.increase(2 * WEEK + toBeMatured.toNumber() + 1); - - // commit epoch, so more reward is added that must not be claimed now - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // prepare params for call - let { epochNum, topUpIndex } = await retrieveRPSData( - validatorSet, - rewardPool, - delegatedValidator.address, - vestManager.address - ); - - // 1 because we have only one top-up - topUpIndex = 1; - - const areRewardsMatured = position.end.add(toBeMatured).lt(await time.latest()); - expect(areRewardsMatured, "areRewardsMatured").to.be.true; - - await expect( - await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex), - "claimVestedPositionReward" - ).to.changeEtherBalances( - [hre.ethers.constants.AddressZero, vestManagerOwner.address, rewardPool.address], - [maxReward.sub(expectedReward), expectedReward, maxReward.mul(-1)] - ); - }); - - it("should properly claim reward when top-ups and full reward matured", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = - await loadFixture(this.fixtures.weeklyVestedDelegationFixture); - - // calculate base rewards - const baseReward = await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address); - const base = await rewardPool.base(); - const vestBonus = await rewardPool.getVestingBonus(1); - const rsi = await rewardPool.rsi(); - const expectedBaseReward = await calculateExpectedReward(base, vestBonus, rsi, baseReward); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // calculate top-up reward - const topUpReward = (await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address)).sub( - baseReward - ); - const expectedTopUpReward = await calculateExpectedReward(base, vestBonus, MIN_RSI_BONUS, topUpReward); - - const expectedReward = expectedBaseReward.add(expectedTopUpReward); - - // calculate max reward - const maxRSI = await rewardPool.MAX_RSI_BONUS(); - const maxVestBonus = await rewardPool.getVestingBonus(52); - const maxBaseReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - const maxTopUpReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, topUpReward); - - const maxReward = maxBaseReward.add(maxTopUpReward); - - // enter the maturing state - // 52 weeks is the duration + the needed time for the top-up to be matured - await time.increase(WEEK * 104 * 4 + 1); - - // commit epoch, so more reward is added that must be without bonus - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - const additionalReward = ( - await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address) - ).sub(baseReward.add(topUpReward)); - - const expectedAdditionalReward = base.mul(additionalReward).div(10000).div(EPOCHS_YEAR); - const maxAdditionalReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, additionalReward); - - // prepare params for call - let { position, epochNum, topUpIndex } = await retrieveRPSData( - validatorSet, - rewardPool, - delegatedValidator.address, - vestManager.address - ); - - // 1 because we have only one top-up, but the first is for the openDelegatorPosition - topUpIndex = 1; - - // ensure rewards are matured - const areRewardsMatured = position.end.add(position.duration).lt(await time.latest()); - expect(areRewardsMatured, "areRewardsMatured").to.be.true; - - const expectedFinalReward = expectedReward.add(expectedAdditionalReward); - const maxFinalReward = maxReward.add(maxAdditionalReward); - - await expect( - await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex), - "claimVestedPositionReward" - ).to.changeEtherBalances( - [hre.ethers.constants.AddressZero, vestManagerOwner.address, rewardPool.address], - [maxFinalReward.sub(expectedFinalReward), expectedFinalReward, maxFinalReward.mul(-1)] - ); - }); - - it("should revert when invalid top-up index", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, delegatedValidator } = await loadFixture( - this.fixtures.weeklyVestedDelegationFixture - ); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - const topUpRewardsTimestamp = await time.latest(); - const position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - const toBeMatured = hre.ethers.BigNumber.from(topUpRewardsTimestamp).sub(position.start); - - // enter the maturing state - // two week is the duration + the needed time for the top-up to be matured - await time.increase(WEEK * 104 + toBeMatured.toNumber() + 1); - - // comit epoch, so more reward is added that must not be claimed now - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // prepare params for call - let { epochNum, topUpIndex } = await retrieveRPSData( - validatorSet, - rewardPool, - delegatedValidator.address, - vestManager.address - ); - - // set invalid index - topUpIndex = 2; - - // ensure rewards are maturing - const areRewardsMatured = position.end.add(toBeMatured).lt(await time.latest()); - expect(areRewardsMatured).to.be.true; - - await expect(vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex)) - .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") - .withArgs("vesting", "INVALID_TOP_UP_INDEX"); - }); - - it("should revert when later top-up index", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, delegatedValidator } = await loadFixture( - this.fixtures.weeklyVestedDelegationFixture - ); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // add another top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // enter the maturing state - // 52 weeks is the duration + the needed time for the top-up to be matured - await time.increase(WEEK * 104 + 1); - - // commit epoch, so more reward is added that must not be claimed now - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // prepare params for call - let { epochNum, topUpIndex } = await retrieveRPSData( - validatorSet, - rewardPool, - delegatedValidator.address, - vestManager.address - ); - - // set later index - topUpIndex = 2; - - await expect(vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum - 1, topUpIndex)) - .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") - .withArgs("vesting", "LATER_TOP_UP"); - }); - - it("should revert when earlier top-up index", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, delegatedValidator } = await loadFixture( - this.fixtures.weeklyVestedDelegationFixture - ); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // enter the maturing state - // reward to be matured - await time.increase(WEEK * 104); - - // prepare params for call - const { epochNum, topUpIndex } = await retrieveRPSData( - validatorSet, - rewardPool, - delegatedValidator.address, - vestManager.address - ); - - await expect(vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex)) - .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") - .withArgs("vesting", "EARLIER_TOP_UP"); - }); - - it("should claim only reward made before top-up", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = - await loadFixture(this.fixtures.weeklyVestedDelegationFixture); - - // calculate base rewards - const baseReward = await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address); - const base = await rewardPool.base(); - const vestBonus = await rewardPool.getVestingBonus(1); - const rsi = await rewardPool.rsi(); - const expectedBaseReward = await calculateExpectedReward(base, vestBonus, rsi, baseReward); - - const maxRSI = await rewardPool.MAX_RSI_BONUS(); - const maxVestBonus = await rewardPool.getVestingBonus(52); - const maxBaseReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - - const rewardDistributionTime = await time.latest(); - let position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - const toBeMatured = hre.ethers.BigNumber.from(rewardDistributionTime).sub(position.start); - time.increase(50); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - // commit epoch, so more reward is added that must be without bonus - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - // enter the maturing state - await time.increaseTo(position.end.toNumber() + toBeMatured.toNumber() + 1); - - // prepare params for call - const currentEpochId = await validatorSet.currentEpochId(); - const rpsValues = await rewardPool.getRPSValues(delegatedValidator.address, 0, currentEpochId); - const epochNum = findProperRPSIndex(rpsValues, position.start.add(toBeMatured)); - const topUpIndex = 0; - - // ensure rewards are maturing - const areRewardsMaturing = position.end.add(toBeMatured).lt(await time.latest()); - expect(areRewardsMaturing).to.be.true; - - await expect( - await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex), - "claimVestedPositionReward" - ).to.changeEtherBalances( - [hre.ethers.constants.AddressZero, vestManagerOwner.address, rewardPool.address], - [maxBaseReward.sub(expectedBaseReward), expectedBaseReward, maxBaseReward.mul(-1)] - ); - }); - - it("should claim rewards multiple times", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager, vestManagerOwner, delegatedValidator } = - await loadFixture(this.fixtures.weeklyVestedDelegationFixture); - - // calculate rewards - const baseReward = await rewardPool.getRawDelegatorReward(delegatedValidator.address, vestManager.address); - const base = await rewardPool.base(); - const vestBonus = await rewardPool.getVestingBonus(1); - const rsi = await rewardPool.rsi(); - const reward = await calculateExpectedReward(base, vestBonus, rsi, baseReward); - - const maxRSI = await rewardPool.MAX_RSI_BONUS(); - const maxVestBonus = await rewardPool.getVestingBonus(52); - const maxBaseReward = await calculateExpectedReward(base, maxVestBonus, maxRSI, baseReward); - - const rewardDistributionTime = await time.latest(); - let position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - const toBeMatured = hre.ethers.BigNumber.from(rewardDistributionTime).sub(position.start); - time.increase(50); - - // top-up - await vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation }); - - // more rewards to be distributed but with the top-up data - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - // commit epoch, so more reward is added that must be without bonus - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - position = await rewardPool.delegationPositions(delegatedValidator.address, vestManager.address); - - // enter the maturing state - await time.increaseTo(position.end.toNumber() + toBeMatured.toNumber() + 1); - - // prepare params for call - const currentEpochId = await validatorSet.currentEpochId(); - const rpsValues = await rewardPool.getRPSValues(delegatedValidator.address, 0, currentEpochId); - const epochNum = findProperRPSIndex(rpsValues, position.start.add(toBeMatured)); - const topUpIndex = 0; - - // ensure rewards are maturing - const areRewardsMaturing = position.end.add(toBeMatured).lt(await time.latest()); - expect(areRewardsMaturing).to.be.true; - - await expect( - await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum, topUpIndex), - "claimVestedPositionReward" - ).to.changeEtherBalances([vestManagerOwner.address, rewardPool.address], [reward, maxBaseReward.mul(-1)]); - - time.increase(WEEK * 2); - - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - expect(await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum + 1, topUpIndex + 1)).to - .not.be.reverted; - - time.increase(WEEK * 52); - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], delegatedValidator], - this.epochSize - ); - - expect(await vestManager.claimVestedPositionReward(delegatedValidator.address, epochNum + 1, topUpIndex + 1)).to - .not.be.reverted; - }); }); } diff --git a/test/ValidatorSet/Delegation.test.ts b/test/ValidatorSet/Delegation.test.ts index 48d7f17c..bd018e81 100644 --- a/test/ValidatorSet/Delegation.test.ts +++ b/test/ValidatorSet/Delegation.test.ts @@ -6,13 +6,14 @@ import * as hre from "hardhat"; // eslint-disable-next-line camelcase import { VestManager__factory } from "../../typechain-types"; import { ERRORS, VESTING_DURATION_WEEKS, WEEK } from "../constants"; -import { calculatePenalty, claimPositionRewards, commitEpoch, commitEpochs, getUserManager } from "../helper"; +import { calculatePenalty, claimPositionRewards, commitEpochs, getUserManager } from "../helper"; import { RunDelegateClaimTests, RunVestedDelegateClaimTests, RunVestedDelegationRewardsTests, RunDelegateFunctionsByValidatorSet, } from "../RewardPool/RewardPool.test"; +import { RunSwapVestedPositionValidatorTests } from "./SwapVestedPositionValidator.test"; export function RunDelegationTests(): void { describe("Change minDelegate", function () { @@ -680,180 +681,6 @@ export function RunDelegationTests(): void { }); }); - describe("topUpVestedDelegatePosition()", async function () { - it("should revert when not owner of the vest manager", async function () { - const { vestManager } = await loadFixture(this.fixtures.vestedDelegationFixture); - - await expect( - vestManager - .connect(this.signers.accounts[10]) - .topUpVestedDelegatePosition(this.signers.accounts[10].address, { value: this.minDelegation }) - ).to.be.revertedWith(ERRORS.ownable); - }); - - it("should revert when not manager", async function () { - const { validatorSet } = await loadFixture(this.fixtures.vestedDelegationFixture); - - await expect( - validatorSet - .connect(this.signers.accounts[10]) - .topUpDelegatePosition(this.signers.accounts[10].address, { value: this.minDelegation }) - ).to.be.revertedWithCustomError(validatorSet, "NotVestingManager"); - }); - - it("should revert when delegation too low", async function () { - const { validatorSet, vestManager } = await loadFixture(this.fixtures.vestedDelegationFixture); - - await expect( - vestManager.topUpVestedDelegatePosition(this.signers.validators[0].address, { - value: this.minDelegation.sub(1), - }) - ) - .to.be.revertedWithCustomError(validatorSet, "DelegateRequirement") - .withArgs("vesting", "DELEGATION_TOO_LOW"); - }); - - it("should revert when position is not active", async function () { - const { validatorSet, vestManager, delegatedValidator } = await loadFixture( - this.fixtures.vestedDelegationFixture - ); - - // enter the reward maturity phase in order to make the position inactive - await time.increase(WEEK * 55); - await expect(vestManager.topUpVestedDelegatePosition(delegatedValidator.address, { value: this.minDelegation })) - .to.be.revertedWithCustomError(validatorSet, "DelegateRequirement") - .withArgs("vesting", "POSITION_NOT_ACTIVE"); - }); - - it("should properly top-up position", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager } = await loadFixture( - this.fixtures.vestManagerFixture - ); - - const duration = 1; // 1 week - await vestManager.openVestedDelegatePosition(this.delegatedValidators[0], duration, { - value: this.minDelegation, - }); - const positionEndBefore = ( - await rewardPool.delegationPositions(this.delegatedValidators[0], vestManager.address) - ).end; - - // enter the active state - await time.increase(1); - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], this.signers.validators[2]], - this.epochSize - ); - - // ensure position is active - const isActive = await rewardPool.isActiveDelegatePosition(this.delegatedValidators[0], vestManager.address); - expect(isActive, "isActive").to.be.true; - - const delegatedAmount = await rewardPool.delegationOf(this.delegatedValidators[0], vestManager.address); - const topUpAmount = this.minDelegation.div(2); - const totalAmount = delegatedAmount.add(topUpAmount); - - await vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: topUpAmount }); - - // delegation is increased - expect( - await rewardPool.delegationOf(this.delegatedValidators[0], vestManager.address), - "delegationOf" - ).to.be.eq(totalAmount); - - // balance change data is added - const balanceChange = await rewardPool.delegationPoolParamsHistory( - this.delegatedValidators[0], - vestManager.address, - 1 - ); - expect(balanceChange.balance, "balanceChange.balance").to.be.eq(totalAmount); - expect(balanceChange.epochNum, "balanceChange.epochNum").to.be.eq(await validatorSet.currentEpochId()); - - // duration increase is proper - const positionEndAfter = ( - await rewardPool.delegationPositions(this.delegatedValidators[0], vestManager.address) - ).end; - expect(positionEndAfter).to.be.eq(positionEndBefore.add((duration * WEEK) / 2)); - }); - - it("should revert when too many top-ups are made", async function () { - const { systemValidatorSet, validatorSet, rewardPool, vestManager } = await loadFixture( - this.fixtures.vestedDelegationFixture - ); - - const maxTopUps = 52; // one cannot top-up more than 52 times - for (let i = 0; i < maxTopUps; i++) { - const delegatingAmount = this.minDelegation.mul(i + 1).div(5); - await vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: delegatingAmount }); - - // commit epoch cause only 1 top-up can be made per epoch - await commitEpoch( - systemValidatorSet, - rewardPool, - [this.signers.validators[0], this.signers.validators[1], this.signers.validators[2]], - this.epochSize - ); - } - - await expect( - vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: this.minDelegation }) - ) - .to.be.revertedWithCustomError(validatorSet, "DelegateRequirement") - .withArgs("vesting", "TOO_MANY_TOP_UPS"); - }); - - it("should revert when top-up already made in the same epoch", async function () { - const { validatorSet, vestManager } = await loadFixture(this.fixtures.vestedDelegationFixture); - - await vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: this.minDelegation }); - - await expect( - vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: this.minDelegation }) - ) - .to.be.revertedWithCustomError(validatorSet, "DelegateRequirement") - .withArgs("_onAccountParamsChange", "BALANCE_CHANGE_ALREADY_MADE"); - }); - - it("should increase duration no more than 100%", async function () { - const { rewardPool, vestManager } = await loadFixture(this.fixtures.vestedDelegationFixture); - - const positionBeforeTopUp = await rewardPool.delegationPositions( - this.delegatedValidators[0], - vestManager.address - ); - - const topUpAmount = (await rewardPool.delegationOf(this.delegatedValidators[0], vestManager.address)).mul(2); - await vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { - value: topUpAmount.add(this.minDelegation), - }); - - const vestingEndAfter = (await rewardPool.delegationPositions(this.delegatedValidators[0], vestManager.address)) - .end; - expect(vestingEndAfter, "vestingEndAfter").to.be.eq(positionBeforeTopUp.end.add(positionBeforeTopUp.duration)); - }); - - it("should revert when top-up closed position", async function () { - const { validatorSet, rewardPool, liquidToken, vestManager } = await loadFixture( - this.fixtures.vestedDelegationFixture - ); - - // close position - const delegatedAmount = await rewardPool.delegationOf(this.delegatedValidators[0], vestManager.address); - await liquidToken.connect(this.vestManagerOwners[0]).approve(vestManager.address, delegatedAmount); - await vestManager.cutVestedDelegatePosition(this.delegatedValidators[0], delegatedAmount); - - // top-up - await expect( - vestManager.topUpVestedDelegatePosition(this.delegatedValidators[0], { value: this.minDelegation }) - ) - .to.be.revertedWithCustomError(validatorSet, "DelegateRequirement") - .withArgs("vesting", "POSITION_NOT_ACTIVE"); - }); - }); - describe("Reward Pool - rewards", async function () { RunVestedDelegationRewardsTests(); }); @@ -862,6 +689,10 @@ export function RunDelegationTests(): void { RunVestedDelegateClaimTests(); }); + describe("Reward Pool - Vested delegate swap", async function () { + RunSwapVestedPositionValidatorTests(); + }); + describe("Reward Pool - ValidatorSet protected delegate functions", function () { RunDelegateFunctionsByValidatorSet(); }); diff --git a/test/ValidatorSet/SwapVestedPositionValidator.test.ts b/test/ValidatorSet/SwapVestedPositionValidator.test.ts new file mode 100644 index 00000000..35ac8de4 --- /dev/null +++ b/test/ValidatorSet/SwapVestedPositionValidator.test.ts @@ -0,0 +1,368 @@ +/* eslint-disable node/no-extraneous-import */ +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; + +import { DAY, ERRORS, WEEK } from "../constants"; +import { commitEpoch, commitEpochs, retrieveRPSData } from "../helper"; + +export function RunSwapVestedPositionValidatorTests(): void { + describe("Delegate position rewards", async function () { + it("should revert when not the vest manager owner", async function () { + const { vestManager, delegatedValidator } = await loadFixture(this.fixtures.weeklyVestedDelegationFixture); + + await expect( + vestManager + .connect(this.signers.accounts[10]) + .swapVestedPositionValidator(delegatedValidator.address, delegatedValidator.address) + ).to.be.revertedWith("Ownable: caller is not the owner"); + }); + + it("should revert that the old position is inactive", async function () { + const { systemValidatorSet, vestManager, delegatedValidator, rewardPool, vestManagerOwner } = await loadFixture( + this.fixtures.weeklyVestedDelegationFixture + ); + + await commitEpoch( + systemValidatorSet, + rewardPool, + [this.signers.validators[0], this.signers.validators[1], delegatedValidator], + this.epochSize, + // increase time to make the position maturing + WEEK + ); + + // ensure is not active position + expect(await rewardPool.isActiveDelegatePosition(delegatedValidator.address, vestManager.address), "isActive").be + .false; + + await expect( + vestManager + .connect(vestManagerOwner) + .swapVestedPositionValidator(this.signers.validators[0].address, this.signers.validators[1].address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", "OLD_POSITION_INACTIVE"); + }); + + it("should revert when we try to swap to active position", async function () { + const { vestManager, liquidToken, vestManagerOwner, rewardPool } = await loadFixture( + this.fixtures.vestManagerFixture + ); + + const oldValidator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, this.minDelegation); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(oldValidator.address, 1, { value: this.minDelegation }); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(newValidator.address, 1, { value: this.minDelegation }); + + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", ERRORS.swap.newPositionUnavilable); + }); + + it("should revert when we try to swap to new position which still matures", async function () { + const { systemValidatorSet, vestManager, liquidToken, vestManagerOwner, rewardPool } = await loadFixture( + this.fixtures.vestManagerFixture + ); + + const oldValidator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, this.minDelegation); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(oldValidator.address, 2, { value: this.minDelegation }); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(newValidator.address, 1, { value: this.minDelegation }); + + // commit 8 epochs with 1 day increase before each, so, the first position gonna start maturing + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 8, this.epochSize, DAY); + + const newPosition = await rewardPool.delegationPositions(newValidator.address, vestManager.address); + expect(newPosition.end.add(newPosition.duration).gt(await time.latest()), "Not matured").to.be.true; + expect(newPosition.end.lt(await time.latest()), "isMaturing").to.be.true; + + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", ERRORS.swap.newPositionUnavilable); + }); + + it("should revert when we try to swap to a position with left balance", async function () { + const { systemValidatorSet, vestManager, liquidToken, vestManagerOwner, rewardPool } = await loadFixture( + this.fixtures.vestManagerFixture + ); + + const oldValidator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, this.minDelegation); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(oldValidator.address, 5, { value: this.minDelegation }); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(newValidator.address, 1, { value: this.minDelegation }); + + // commit 5 epochs with 3 days increase before each, so, the new position will be matured and have some balance left + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 5, this.epochSize, DAY * 3); + + // prepare params for call + const { epochNum, topUpIndex } = await retrieveRPSData( + systemValidatorSet, + rewardPool, + newValidator.address, + vestManager.address + ); + + // claim rewards only + await vestManager.connect(vestManagerOwner).claimVestedPositionReward(newValidator.address, epochNum, topUpIndex); + + // verify that there is delegated balance left + expect(await rewardPool.delegationOf(newValidator.address, vestManager.address), "delegationOf").to.not.be.eq(0); + + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", ERRORS.swap.newPositionUnavilable); + }); + + it("should revert when we try to swap to a position with left rewards to claim", async function () { + const { systemValidatorSet, vestManager, liquidToken, vestManagerOwner, rewardPool } = await loadFixture( + this.fixtures.vestManagerFixture + ); + + const oldValidator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, this.minDelegation); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(oldValidator.address, 5, { value: this.minDelegation }); + await vestManager + .connect(vestManagerOwner) + .openVestedDelegatePosition(newValidator.address, 1, { value: this.minDelegation }); + + // commit 5 epochs with 3 days increase before each, so, the new position will be matured and have some balance left + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 5, this.epochSize, DAY * 3); + + // undelegate full amount + await vestManager.connect(vestManagerOwner).cutVestedDelegatePosition(newValidator.address, this.minDelegation); + + // verify that there are rewards left to claim + const { epochNum, topUpIndex } = await retrieveRPSData( + systemValidatorSet, + rewardPool, + newValidator.address, + vestManager.address + ); + + expect( + await rewardPool.getDelegatorPositionReward(newValidator.address, vestManager.address, epochNum, topUpIndex), + "getDelegatorPositionReward" + ).to.not.be.eq(0); + + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", ERRORS.swap.newPositionUnavilable); + }); + + it("should transfer old position parameters to the new one on successful swap", async function () { + const { systemValidatorSet, rewardPool, vestManager, vestManagerOwner, liquidToken } = await loadFixture( + this.fixtures.vestManagerFixture + ); + + const validator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + + const vestingDuration = 2; // 2 weeks + await vestManager.connect(vestManagerOwner).openVestedDelegatePosition(validator.address, vestingDuration, { + value: this.minDelegation.mul(2), + }); + + await commitEpoch(systemValidatorSet, rewardPool, [validator, newValidator], this.epochSize); + + const amount = await rewardPool.delegationOf(validator.address, vestManager.address); + + // give allowance & swap + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, amount); + await vestManager.connect(vestManagerOwner).swapVestedPositionValidator(validator.address, newValidator.address); + + const oldPosition = await rewardPool.delegationPositions(validator.address, vestManager.address); + const newPosition = await rewardPool.delegationPositions(newValidator.address, vestManager.address); + + // expect new position to be like the old position + expect(oldPosition.duration, "oldPosition.duration").to.be.eq(newPosition.duration); + expect(oldPosition.start, "oldPosition.start").to.be.eq(newPosition.start); + expect(oldPosition.end, "oldPosition.end").to.be.eq(newPosition.end); + expect(oldPosition.base, "oldPosition.base").to.be.eq(newPosition.base); + expect(oldPosition.vestBonus, "oldPosition.vestBonus").to.be.eq(newPosition.vestBonus); + expect(oldPosition.rsiBonus, "oldPosition.rsiBonus").to.be.eq(newPosition.rsiBonus); + }); + + it("should start earning rewards on new position after swap", async function () { + const { systemValidatorSet, rewardPool, vestManager, oldValidator, newValidator } = await loadFixture( + this.fixtures.swappedPositionFixture + ); + + await commitEpoch(systemValidatorSet, rewardPool, [oldValidator, newValidator], this.epochSize); + + const rewardsAfterSwap = await rewardPool.getRawDelegatorReward(newValidator.address, vestManager.address); + expect(rewardsAfterSwap, "rewardsAfterSwap").to.be.gt(0); + }); + + it("should stop earning rewards on old position after swap", async function () { + const { systemValidatorSet, rewardPool, vestManager, oldValidator, newValidator, rewardsBeforeSwap } = + await loadFixture(this.fixtures.swappedPositionFixture); + + await commitEpoch(systemValidatorSet, rewardPool, [oldValidator, newValidator], this.epochSize); + + const rewardsAfterSwap = await rewardPool.getRawDelegatorReward(oldValidator.address, vestManager.address); + expect(rewardsAfterSwap, "rewardsAfterSwap").to.be.eq(rewardsBeforeSwap).and.to.be.gt(0); + }); + + it("should revert when pass incorrect swap index", async function () { + const { systemValidatorSet, rewardPool, vestManager, vestManagerOwner, oldValidator, newValidator } = + await loadFixture(this.fixtures.swappedPositionFixture); + + // commit epochs and increase time to make the position matured & commit epochs + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 4, this.epochSize, WEEK); + + const rewardsBeforeClaim = await rewardPool.getRawDelegatorReward(newValidator.address, vestManager.address); + expect(rewardsBeforeClaim).to.be.gt(0); + + // prepare params for call + const { epochNum, topUpIndex } = await retrieveRPSData( + systemValidatorSet, + rewardPool, + newValidator.address, + vestManager.address + ); + + await expect( + vestManager.connect(vestManagerOwner).claimVestedPositionReward(newValidator.address, epochNum, topUpIndex + 1) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("vesting", "INVALID_TOP_UP_INDEX"); + }); + + it("should claim all rewards from the new position after swap", async function () { + const { systemValidatorSet, rewardPool, vestManager, vestManagerOwner, oldValidator, newValidator } = + await loadFixture(this.fixtures.swappedPositionFixture); + + // commit epochs and increase time to make the position matured & commit epochs + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 4, this.epochSize, WEEK); + + const rewardsBeforeClaim = await rewardPool.getRawDelegatorReward(newValidator.address, vestManager.address); + expect(rewardsBeforeClaim).to.be.gt(0); + + // prepare params for call + const { epochNum, topUpIndex } = await retrieveRPSData( + systemValidatorSet, + rewardPool, + newValidator.address, + vestManager.address + ); + + await vestManager.connect(vestManagerOwner).claimVestedPositionReward(newValidator.address, epochNum, topUpIndex); + + const rewardsAfterClaim = await rewardPool.getRawDelegatorReward(newValidator.address, vestManager.address); + expect(rewardsAfterClaim).to.be.eq(0); + }); + + it("should claim all rewards from the old position after swap when the reward is matured", async function () { + const { systemValidatorSet, rewardPool, vestManager, vestManagerOwner, oldValidator, newValidator } = + await loadFixture(this.fixtures.swappedPositionFixture); + + // commit epochs and increase time to make the position matured & commit epochs + await commitEpochs(systemValidatorSet, rewardPool, [oldValidator, newValidator], 3, this.epochSize, WEEK); + + const rewardsBeforeClaim = await rewardPool.getRawDelegatorReward(oldValidator.address, vestManager.address); + expect(rewardsBeforeClaim, "rewardsBeforeClaim").to.be.gt(0); + + // prepare params for call + const { epochNum, topUpIndex } = await retrieveRPSData( + systemValidatorSet, + rewardPool, + oldValidator.address, + vestManager.address + ); + + await vestManager.connect(vestManagerOwner).claimVestedPositionReward(oldValidator.address, epochNum, topUpIndex); + + const rewardsAfterClaim = await rewardPool.getRawDelegatorReward(oldValidator.address, vestManager.address); + expect(rewardsAfterClaim, "rewardsAfterClaim").to.be.eq(0); + }); + + it("should revert when try to swap again during the same epoch", async function () { + const { + rewardPool, + vestManager, + vestManagerOwner, + newValidator: oldValidator, + liquidToken, + } = await loadFixture(this.fixtures.swappedPositionFixture); + + const newValidator = this.signers.validators[2]; + const amount = await rewardPool.delegationOf(oldValidator.address, vestManager.address); + + // give allowance + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, amount); + + // try to swap + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("_saveAccountParamsChange", "BALANCE_CHANGE_ALREADY_MADE"); + }); + + // TODO: Consider deleting it as we shouldn't be getting into that case + it.skip("should revert when try to swap too many times", async function () { + const { + systemValidatorSet, + rewardPool, + vestManager, + vestManagerOwner, + newValidator: oldValidator, + liquidToken, + } = await loadFixture(this.fixtures.swappedPositionFixture); + + const newValidator = this.signers.validators[2]; + + await commitEpoch(systemValidatorSet, rewardPool, [oldValidator, newValidator], this.epochSize, 60 * 60); + + const amount = await rewardPool.delegationOf(oldValidator.address, vestManager.address); + + const balanceChangesThreshold = (await rewardPool.balanceChangeThreshold()).toNumber(); + for (let i = 0; i < balanceChangesThreshold; i++) { + const _oldValidator = i % 2 === 0 ? oldValidator : newValidator; + const _newValidator = i % 2 === 0 ? newValidator : oldValidator; + + // give allowance + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, amount); + await vestManager + .connect(vestManagerOwner) + .swapVestedPositionValidator(_oldValidator.address, _newValidator.address); + await commitEpoch(systemValidatorSet, rewardPool, [oldValidator, newValidator], this.epochSize, 60 * 60); + } + + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, amount); + + // try to swap to exceed the number of the allowed balance changes + await expect( + vestManager.connect(vestManagerOwner).swapVestedPositionValidator(oldValidator.address, newValidator.address) + ) + .to.be.revertedWithCustomError(rewardPool, "DelegateRequirement") + .withArgs("_saveAccountParamsChange", "BALANCE_CHANGES_EXCEEDED"); + }); + }); +} diff --git a/test/constants.ts b/test/constants.ts index b7b610b8..43e0dbcd 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -10,7 +10,8 @@ export const VALIDATOR_PKCHECK_PRECOMPILE_GAS = 150000; export const CHAIN_ID = 31337; export const INITIAL_COMMISSION = ethers.BigNumber.from(10); export const MAX_COMMISSION = ethers.BigNumber.from(100); -export const WEEK = 60 * 60 * 24 * 7; +export const DAY = 60 * 60 * 24; +export const WEEK = DAY * 7; export const VESTING_DURATION_WEEKS = 10; // in weeks export const EPOCHS_YEAR = ethers.BigNumber.from(31500); export const INITIAL_BASE_APR = ethers.BigNumber.from(500); @@ -38,6 +39,9 @@ export const ERRORS = { ownable: "Ownable: caller is not the owner", inactiveValidator: "INACTIVE_VALIDATOR", invalidValidator: "INVALID_VALIDATOR", + swap: { + newPositionUnavilable: "NEW_POSITION_UNAVAILABLE", + }, accessControl: (account: string, role: string) => { return `AccessControl: account ${account} is missing role ${role}`; }, diff --git a/test/fixtures.ts b/test/fixtures.ts index 727cd199..8f847c16 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -19,6 +19,7 @@ import { registerValidator, createNewVestManager, generateValidatorBls, + commitEpoch, } from "./helper"; async function systemFixtureFunction(this: Mocha.Context) { @@ -461,6 +462,41 @@ async function bannedValidatorFixtureFunction(this: Mocha.Context) { }; } +async function swappedPositionFixtureFunction(this: Mocha.Context) { + const { validatorSet, systemValidatorSet, rewardPool, liquidToken, vestManager, vestManagerOwner } = + await loadFixture(this.fixtures.vestManagerFixture); + + const validator = this.signers.validators[0]; + const newValidator = this.signers.validators[1]; + + const vestingDuration = 2; // 2 weeks + await vestManager.connect(vestManagerOwner).openVestedDelegatePosition(validator.address, vestingDuration, { + value: this.minDelegation.mul(2), + }); + + await commitEpoch(systemValidatorSet, rewardPool, [validator, newValidator], this.epochSize); + + const rewardsBeforeSwap = await rewardPool.getRawDelegatorReward(validator.address, vestManager.address); + + const amount = await rewardPool.delegationOf(validator.address, vestManager.address); + + // give allowance & swap + await liquidToken.connect(vestManagerOwner).approve(vestManager.address, amount); + await vestManager.connect(vestManagerOwner).swapVestedPositionValidator(validator.address, newValidator.address); + + return { + validatorSet, + systemValidatorSet, + rewardPool, + liquidToken, + vestManager, + vestManagerOwner, + oldValidator: validator, + newValidator, + rewardsBeforeSwap, + }; +} + async function blsFixtureFunction(this: Mocha.Context) { const BLSFactory = new BLS__factory(this.signers.admin); const BLS = await BLSFactory.deploy(); @@ -499,4 +535,5 @@ export async function generateFixtures(context: Mocha.Context) { context.fixtures.weeklyVestedDelegationFixture = weeklyVestedDelegationFixtureFunction.bind(context); context.fixtures.validatorToBanFixture = validatorToBanFixtureFunction.bind(context); context.fixtures.bannedValidatorFixture = bannedValidatorFixtureFunction.bind(context); + context.fixtures.swappedPositionFixture = swappedPositionFixtureFunction.bind(context); } diff --git a/test/helper.ts b/test/helper.ts index 9661370a..6979d6a2 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -1,7 +1,7 @@ /* eslint-disable camelcase */ /* eslint-disable node/no-extraneous-import */ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; -import { mine } from "@nomicfoundation/hardhat-network-helpers"; +import { mine, time } from "@nomicfoundation/hardhat-network-helpers"; import * as hre from "hardhat"; import { BigNumber, ContractTransaction } from "ethers"; @@ -11,7 +11,7 @@ import { ValidatorSet } from "../typechain-types/contracts/ValidatorSet"; import { RewardPool } from "../typechain-types/contracts/RewardPool"; import { VestManager } from "../typechain-types/contracts/ValidatorSet/modules/Delegation"; import { VestManager__factory } from "../typechain-types/factories/contracts/ValidatorSet/modules/Delegation"; -import { CHAIN_ID, DENOMINATOR, DOMAIN, EPOCHS_YEAR, INITIAL_COMMISSION, SYSTEM, WEEK } from "./constants"; +import { CHAIN_ID, DAY, DENOMINATOR, DOMAIN, EPOCHS_YEAR, INITIAL_COMMISSION, SYSTEM, WEEK } from "./constants"; interface RewardParams { timestamp: BigNumber; @@ -61,7 +61,8 @@ export async function commitEpoch( systemValidatorSet: ValidatorSet, rewardPool: RewardPool, validators: SignerWithAddress[], - epochSize: BigNumber + epochSize: BigNumber, + increaseTime?: number ): Promise<{ commitEpochTx: ContractTransaction; distributeRewardsTx: ContractTransaction }> { const currEpochId = await systemValidatorSet.currentEpochId(); const prevEpochId = currEpochId.sub(1); @@ -78,6 +79,8 @@ export async function commitEpoch( } await mine(epochSize, { interval: 2 }); + increaseTime = increaseTime || DAY; // default 1 day + await time.increase(increaseTime); const commitEpochTx = await systemValidatorSet.commitEpoch(currEpochId, newEpoch, epochSize); @@ -96,12 +99,13 @@ export async function commitEpochs( rewardPool: RewardPool, validators: SignerWithAddress[], numOfEpochsToCommit: number, - epochSize: BigNumber + epochSize: BigNumber, + increaseTime?: number ) { if (epochSize.isZero() || numOfEpochsToCommit === 0) return; for (let i = 0; i < numOfEpochsToCommit; i++) { - await commitEpoch(systemValidatorSet, rewardPool, validators, epochSize); + await commitEpoch(systemValidatorSet, rewardPool, validators, epochSize, increaseTime); } } @@ -170,6 +174,39 @@ export function findProperRPSIndex(arr: T[], timestamp: return closestIndex; } +export function findTopUpIndex(arr: any[], epochNum: BigNumber): number { + if (arr.length <= 1) return 0; + + let left = 0; + let right = arr.length - 1; + let closestEpoch: null | BigNumber = null; + let closestIndex: null | number = null; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const midValue = arr[mid].epochNum; + if (midValue.eq(epochNum)) { + // Timestamp found + return mid; + } else if (midValue.lt(epochNum)) { + // Check if the timestamp is closer to the mid + if (closestEpoch === null || epochNum.sub(midValue).abs().lt(epochNum.sub(closestEpoch).abs())) { + closestEpoch = midValue; + closestIndex = mid; + } + left = mid + 1; + } else { + right = mid - 1; + } + } + + if (closestIndex === null) { + throw new Error("findTopUpIndex: Invalid epoch number"); + } + + return closestIndex; +} + export async function calculatePenalty(position: any, timestamp: BigNumber, amount: BigNumber) { const leftPeriod: BigNumber = position.end.sub(timestamp); let leftWeeks = leftPeriod.mod(WEEK); // get the remainder first @@ -235,11 +272,12 @@ export async function retrieveRPSData( manager: string ) { const position = await rewardPool.delegationPositions(validator, manager); - const end = position.end; + const maturedIn = await getClosestMaturedTimestamp(position); const currentEpochId = await validatorSet.currentEpochId(); const rpsValues = await rewardPool.getRPSValues(validator, 0, currentEpochId); - const epochNum = findProperRPSIndex(rpsValues, end); - const topUpIndex = 0; + const epochNum = findProperRPSIndex(rpsValues, hre.ethers.BigNumber.from(maturedIn)); + const delegationPoolParamsHistory = await rewardPool.getDelegationPoolParamsHistory(validator, manager); + const topUpIndex = findTopUpIndex(delegationPoolParamsHistory, hre.ethers.BigNumber.from(epochNum)); return { position, epochNum, topUpIndex }; } @@ -331,3 +369,23 @@ export async function getDelegatorPositionReward( return await rewardPool.getDelegatorPositionReward(validator, delegator, epochNum, topUpIndex); } + +export async function getClosestMaturedTimestamp(position: any) { + let alreadyMatureIn = 0; + if (await hasMatured(position.end, position.duration)) { + alreadyMatureIn = position.end; + } else { + const currChainTs = await time.latest(); + const maturedPeriod = currChainTs - position.end; + alreadyMatureIn = position.start.add(maturedPeriod); + } + + return alreadyMatureIn; +} + +// function that returns whether a position is matured or not +async function hasMatured(positionEnd: BigNumber, positionDuration: BigNumber) { + const currChainTs = await time.latest(); + + return positionEnd && positionDuration && positionEnd.add(positionDuration).lte(currChainTs); +} diff --git a/test/mochaContext.ts b/test/mochaContext.ts index fb82d907..9b8df4c0 100644 --- a/test/mochaContext.ts +++ b/test/mochaContext.ts @@ -169,6 +169,19 @@ export interface Fixtures { stakedAmount: BigNumber; }>; }; + swappedPositionFixture: { + (): Promise<{ + validatorSet: ValidatorSet; + systemValidatorSet: ValidatorSet; + rewardPool: RewardPool; + liquidToken: LiquidityToken; + vestManager: VestManager; + vestManagerOwner: SignerWithAddress; + oldValidator: SignerWithAddress; + newValidator: SignerWithAddress; + rewardsBeforeSwap: BigNumber; + }>; + }; } declare module "mocha" {