diff --git a/.solhint.json b/.solhint.json index ac0a038..b3914c5 100644 --- a/.solhint.json +++ b/.solhint.json @@ -5,6 +5,6 @@ "code-complexity": ["warn", 25], "function-max-lines": ["warn", 160], "func-visibility": ["warn", { "ignoreConstructors": true }], - "max-states-count": ["warn", 35] + "max-states-count": ["warn", 37] } } diff --git a/contracts/StakingHbbft.sol b/contracts/StakingHbbft.sol index 773ce1d..cf17a1e 100644 --- a/contracts/StakingHbbft.sol +++ b/contracts/StakingHbbft.sol @@ -141,11 +141,23 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra IBonusScoreSystem public bonusScoreContract; + /// @dev Address of node operator for specified pool. + mapping(address => address) public poolNodeOperator; + + /// @dev Node operator share percent of total pool rewards. + mapping(address => uint256) public poolNodeOperatorShare; + + /// @dev The epoch number in which the operator's address can be changed. + mapping(address => uint256) internal _poolNodeOperatorLastChangeEpoch; + // ============================================== Constants ======================================================= /// @dev The max number of candidates (including validators). This limit was determined through stress testing. uint256 public constant MAX_CANDIDATES = 3000; + uint256 public constant MAX_NODE_OPERATOR_SHARE_PERCENT = 2000; + uint256 public constant PERCENT_DENOMINATOR = 10000; + // ================================================ Events ======================================================== /// @dev Emitted by the `claimOrderedWithdraw` function to signal the staker withdrew the specified @@ -232,6 +244,16 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra uint256 delegatorsReward ); + /// @dev Emitted by the `_setNodeOperator` function. + /// @param poolStakingAddress The pool for which node operator was configured. + /// @param nodeOperatorAddress Address of node operator address related to `poolStakingAddress`. + /// @param operatorShare Node operator share percent. + event SetNodeOperator( + address indexed poolStakingAddress, + address indexed nodeOperatorAddress, + uint256 operatorShare + ); + /** * @dev Emitted when the minimum stake for a delegator is updated. * @param minStake The new minimum stake value. @@ -246,6 +268,7 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra // ============================================== Errors ======================================================= error CannotClaimWithdrawOrderYet(address pool, address staker); + error OnlyOncePerEpoch(uint256 _epoch); error MaxPoolsCountExceeded(); error MaxAllowedWithdrawExceeded(uint256 allowed, uint256 desired); error NoStakesToRecover(); @@ -268,6 +291,8 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra error InvalidStakingFixedEpochDuration(); error InvalidTransitionTimeFrame(); error InvalidWithdrawAmount(address pool, address delegator, uint256 amount); + error InvalidNodeOperatorConfiguration(address _operator, uint256 _share); + error InvalidNodeOperatorShare(uint256 _share); error WithdrawNotAllowed(); error ZeroWidthrawAmount(); error ZeroWidthrawDisallowPeriod(); @@ -505,15 +530,27 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra /// they want to create a pool. This is a wrapper for the `stake` function. /// @param _miningAddress The mining address of the candidate. The mining address is bound to the staking address /// (msg.sender). This address cannot be equal to `msg.sender`. - function addPool(address _miningAddress, bytes calldata _publicKey, bytes16 _ip) external payable gasPriceIsValid { + /// @param _nodeOperatorAddress Address of node operator, will receive `_operatorShare` of epoch rewards. + /// @param _operatorShare Percent of epoch rewards to send to `_nodeOperatorAddress`. + /// Integer value with 2 decimal places, e.g. 1% = 100, 10.25% = 1025. + function addPool( + address _miningAddress, + address _nodeOperatorAddress, + uint256 _operatorShare, + bytes calldata _publicKey, + bytes16 _ip + ) external payable gasPriceIsValid { address stakingAddress = msg.sender; uint256 amount = msg.value; validatorSetContract.setStakingAddress(_miningAddress, stakingAddress); // The staking address and the staker are the same. - _stake(stakingAddress, stakingAddress, amount); poolInfo[stakingAddress].publicKey = _publicKey; poolInfo[stakingAddress].internetAddress = _ip; + _setNodeOperator(stakingAddress, _nodeOperatorAddress, _operatorShare); + + _stake(stakingAddress, stakingAddress, amount); + emit PlacedStake(stakingAddress, stakingAddress, stakingEpoch, amount); } @@ -547,6 +584,17 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra poolInfo[msg.sender].port = _port; } + /// @dev Set's the pool node operator configuration for a specific ethereum address. + /// @param _operatorAddress Node operator address. + /// @param _operatorShare Node operator reward share percent. + function setNodeOperator(address _operatorAddress, uint256 _operatorShare) external { + if (validatorSetContract.miningByStakingAddress(msg.sender) == address(0)) { + revert PoolNotExist(msg.sender); + } + + _setNodeOperator(msg.sender, _operatorAddress, _operatorShare); + } + /// @dev Removes a specified pool from the `pools` array (a list of active pools which can be retrieved by the /// `getPools` getter). Called by the `ValidatorSetHbbft._removeMaliciousValidator` internal function, /// and the `ValidatorSetHbbft.handleFailedKeyGeneration` function @@ -623,38 +671,35 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra uint256 poolReward = msg.value; uint256 totalStake = snapshotPoolTotalStakeAmount[stakingEpoch][_poolStakingAddress]; - uint256 validatorStake = snapshotPoolValidatorStakeAmount[stakingEpoch][_poolStakingAddress]; - uint256 validatorReward = 0; + PoolRewardShares memory shares = _splitPoolReward(_poolStakingAddress, poolReward, _validatorMinRewardPercent); - if (totalStake > validatorStake) { - address[] memory delegators = poolDelegators(_poolStakingAddress); + address[] memory delegators = poolDelegators(_poolStakingAddress); + for (uint256 i = 0; i < delegators.length; ++i) { + uint256 delegatorReward = (shares.delegatorsShare * + _getDelegatorStake(stakingEpoch, _poolStakingAddress, delegators[i])) / totalStake; - uint256 validatorFixedReward = (poolReward * _validatorMinRewardPercent) / 100; - uint256 rewardsToDisribute = poolReward - validatorFixedReward; - - validatorReward = validatorFixedReward + (rewardsToDisribute * validatorStake) / totalStake; - - for (uint256 i = 0; i < delegators.length; ++i) { - uint256 delegatorReward = (rewardsToDisribute * - _getDelegatorStake(stakingEpoch, _poolStakingAddress, delegators[i])) / totalStake; + stakeAmount[_poolStakingAddress][delegators[i]] += delegatorReward; + _stakeAmountByEpoch[_poolStakingAddress][delegators[i]][stakingEpoch] += delegatorReward; + } - stakeAmount[_poolStakingAddress][delegators[i]] += delegatorReward; - _stakeAmountByEpoch[_poolStakingAddress][delegators[i]][stakingEpoch] += delegatorReward; - } - } else { - // Whole pool stake belongs to the pool owner - // and he received all the rewards. - validatorReward = poolReward; + if (shares.nodeOperatorShare != 0) { + _rewardNodeOperator(_poolStakingAddress, shares.nodeOperatorShare); } - stakeAmount[_poolStakingAddress][_poolStakingAddress] += validatorReward; + stakeAmount[_poolStakingAddress][_poolStakingAddress] += shares.validatorShare; + stakeAmountTotal[_poolStakingAddress] += poolReward; totalStakedAmount += poolReward; _setLikelihood(_poolStakingAddress); - emit RestakeReward(_poolStakingAddress, stakingEpoch, validatorReward, poolReward - validatorReward); + emit RestakeReward( + _poolStakingAddress, + stakingEpoch, + shares.validatorShare, + poolReward - shares.validatorShare + ); } /// @dev Orders coins withdrawal from the staking address of the specified pool to the @@ -1433,6 +1478,43 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra } } + function _setNodeOperator( + address _stakingAddress, + address _operatorAddress, + uint256 _operatorSharePercent + ) private { + if (_operatorSharePercent > MAX_NODE_OPERATOR_SHARE_PERCENT) { + revert InvalidNodeOperatorShare(_operatorSharePercent); + } + + if (_operatorAddress == address(0) && _operatorSharePercent != 0) { + revert InvalidNodeOperatorConfiguration(_operatorAddress, _operatorSharePercent); + } + + uint256 lastChangeEpoch = _poolNodeOperatorLastChangeEpoch[_stakingAddress]; + if (lastChangeEpoch != 0 && lastChangeEpoch == stakingEpoch) { + revert OnlyOncePerEpoch(stakingEpoch); + } + + poolNodeOperator[_stakingAddress] = _operatorAddress; + poolNodeOperatorShare[_stakingAddress] = _operatorSharePercent; + + _poolNodeOperatorLastChangeEpoch[_stakingAddress] = stakingEpoch; + + emit SetNodeOperator(_stakingAddress, _operatorAddress, _operatorSharePercent); + } + + function _rewardNodeOperator(address _stakingAddress, uint256 _operatorShare) private { + address nodeOperator = poolNodeOperator[_stakingAddress]; + + if (!_poolDelegators[_stakingAddress].contains(nodeOperator)) { + _addPoolDelegator(_stakingAddress, nodeOperator); + } + + stakeAmount[_stakingAddress][nodeOperator] += _operatorShare; + _stakeAmountByEpoch[_stakingAddress][nodeOperator][stakingEpoch] += _operatorShare; + } + function _getDelegatorStake( uint256 _stakingEpoch, address _stakingAddress, @@ -1469,6 +1551,30 @@ contract StakingHbbft is Initializable, OwnableUpgradeable, ReentrancyGuardUpgra } return (false, 0); } + + function _splitPoolReward( + address _poolAddress, + uint256 _poolReward, + uint256 _validatorMinRewardPercent + ) private view returns (PoolRewardShares memory shares) { + uint256 totalStake = snapshotPoolTotalStakeAmount[stakingEpoch][_poolAddress]; + uint256 validatorStake = snapshotPoolValidatorStakeAmount[stakingEpoch][_poolAddress]; + + uint256 validatorFixedReward = (_poolReward * _validatorMinRewardPercent) / 100; + + shares.delegatorsShare = _poolReward - validatorFixedReward; + + uint256 operatorSharePercent = poolNodeOperatorShare[_poolAddress]; + if (poolNodeOperator[_poolAddress] != address(0) && operatorSharePercent != 0) { + shares.nodeOperatorShare = (_poolReward * operatorSharePercent) / PERCENT_DENOMINATOR; + } + + shares.validatorShare = + validatorFixedReward - + shares.nodeOperatorShare + + (shares.delegatorsShare * validatorStake) / + totalStake; + } } // slither-disable-end unused-return diff --git a/contracts/interfaces/IStakingHbbft.sol b/contracts/interfaces/IStakingHbbft.sol index 0280f8b..3098ff9 100644 --- a/contracts/interfaces/IStakingHbbft.sol +++ b/contracts/interfaces/IStakingHbbft.sol @@ -2,6 +2,12 @@ pragma solidity =0.8.25; interface IStakingHbbft { + struct PoolRewardShares { + uint256 validatorShare; + uint256 nodeOperatorShare; + uint256 delegatorsShare; + } + struct StakingParams { address _validatorSetContract; address _bonusScoreContract; diff --git a/test/BlockRewardHbbft.ts b/test/BlockRewardHbbft.ts index 0a64e5c..0404dca 100644 --- a/test/BlockRewardHbbft.ts +++ b/test/BlockRewardHbbft.ts @@ -1301,6 +1301,8 @@ describe('BlockRewardHbbft', () => { await stakingHbbft.connect(stakingAddress).addPool( miningAddress.address, + ethers.ZeroAddress, + 0n, ethers.zeroPadBytes("0x00", 64), ethers.zeroPadBytes("0x00", 16), { value: MIN_STAKE } diff --git a/test/KeyGenHistory.ts b/test/KeyGenHistory.ts index 0d2e18e..acd000e 100644 --- a/test/KeyGenHistory.ts +++ b/test/KeyGenHistory.ts @@ -586,6 +586,8 @@ describe('KeyGenHistory', () => { await stakingHbbft.connect(await ethers.getSigner(newPoolStakingAddress)).addPool( newPoolMiningAddress, + ethers.ZeroAddress, + 0n, ethers.zeroPadBytes("0x00", 64), ethers.zeroPadBytes("0x00", 16), { value: candidateMinStake } @@ -724,6 +726,8 @@ describe('KeyGenHistory', () => { await stakingHbbft.connect(await ethers.getSigner(poolStakingAddress2)).addPool( poolMiningAddress2, + ethers.ZeroAddress, + 0n, ethers.zeroPadBytes("0x00", 64), ethers.zeroPadBytes("0x00", 16), { value: candidateMinStake } diff --git a/test/StakingHbbft.ts b/test/StakingHbbft.ts index 4ef6c20..70473ad 100644 --- a/test/StakingHbbft.ts +++ b/test/StakingHbbft.ts @@ -254,6 +254,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -262,6 +264,29 @@ describe('StakingHbbft', () => { expect(await stakingHbbft.isPoolActive(candidateStakingAddress.address)).to.be.true; }); + it('should create pool and set node operator configuration', async () => { + const { stakingHbbft } = await helpers.loadFixture(deployContractsFixture); + + expect(await stakingHbbft.isPoolActive(candidateStakingAddress.address)).to.be.false; + + const nodeOperator = accounts[10]; + const nodeOperatorShare = 2000; + + await expect(stakingHbbft.connect(candidateStakingAddress).addPool( + candidateMiningAddress.address, + nodeOperator, + nodeOperatorShare, + ZeroPublicKey, + ZeroIpAddress, + { value: minStake } + )).to.emit(stakingHbbft, "SetNodeOperator") + .withArgs(candidateStakingAddress.address, nodeOperator.address, nodeOperatorShare); + + expect(await stakingHbbft.isPoolActive(candidateStakingAddress.address)).to.be.true; + expect(await stakingHbbft.poolNodeOperator(candidateStakingAddress.address)).to.equal(nodeOperator.address); + expect(await stakingHbbft.poolNodeOperatorShare(candidateStakingAddress.address)).to.equal(nodeOperatorShare); + }); + it('should fail if created with overstaked pool', async () => { const { stakingHbbft } = await helpers.loadFixture(deployContractsFixture); @@ -269,6 +294,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: maxStake + minStake } @@ -281,6 +308,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( ethers.ZeroAddress, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -292,6 +321,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateStakingAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -306,6 +337,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -313,6 +346,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress2).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -320,6 +355,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress2.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -327,6 +364,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateMiningAddress2).addPool( candidateStakingAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -334,6 +373,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateMiningAddress).addPool( candidateStakingAddress2.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -341,6 +382,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateMiningAddress2).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -348,6 +391,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateMiningAddress).addPool( candidateMiningAddress2.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -355,6 +400,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress2).addPool( candidateStakingAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -362,6 +409,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateStakingAddress2.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -369,6 +418,8 @@ describe('StakingHbbft', () => { expect(await stakingHbbft.connect(candidateStakingAddress2).addPool( candidateMiningAddress2.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -380,6 +431,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { gasPrice: 0, value: minStake } @@ -391,6 +444,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: 0n } @@ -402,6 +457,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake }, @@ -411,6 +468,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake }, @@ -422,6 +481,8 @@ describe('StakingHbbft', () => { await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake / 2n } @@ -434,6 +495,8 @@ describe('StakingHbbft', () => { const amount = minStake * 2n; await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: amount } @@ -461,6 +524,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidate1StakingAddress).addPool( candidate1MiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: amount1 } @@ -468,6 +533,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidate2StakingAddress).addPool( candidate2MiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: amount2 } @@ -505,6 +572,8 @@ describe('StakingHbbft', () => { // Try to add a new pool outside of max limit, max limit is 100 in mock contract. await expect(stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -521,6 +590,8 @@ describe('StakingHbbft', () => { await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -531,6 +602,135 @@ describe('StakingHbbft', () => { }); }); + describe('setNodeOperator', async () => { + let candidateMiningAddress: HardhatEthersSigner; + let candidateStakingAddress: HardhatEthersSigner; + + beforeEach(async () => { + candidateMiningAddress = accounts[7]; + candidateStakingAddress = accounts[8]; + }); + + it('should revert for non-existing pool', async () => { + const { stakingHbbft } = await helpers.loadFixture(deployContractsFixture); + + const pool = accounts[15]; + const operator = ethers.Wallet.createRandom().address; + const share = 1000; + + await expect(stakingHbbft.connect(pool).setNodeOperator(operator, share)) + .to.be.revertedWithCustomError(stakingHbbft, "PoolNotExist") + .withArgs(pool.address); + }); + + it('should not allow to change node operator twice within same epoch', async () => { + const { stakingHbbft, validatorSetHbbft } = await helpers.loadFixture(deployContractsFixture); + + await stakingHbbft.connect(candidateStakingAddress).addPool( + candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, + ZeroPublicKey, + ZeroIpAddress, + { value: minStake } + ); + + const operator = ethers.Wallet.createRandom().address; + const share = 1000; + + await stakingHbbft.setValidatorMockSetAddress(accounts[7].address); + await stakingHbbft.connect(accounts[7]).incrementStakingEpoch(); + await stakingHbbft.setValidatorMockSetAddress(await validatorSetHbbft.getAddress()); + + + await stakingHbbft.connect(candidateStakingAddress).setNodeOperator(operator, share); + expect(await stakingHbbft.poolNodeOperator(candidateStakingAddress.address)).to.equal(operator); + expect(await stakingHbbft.poolNodeOperatorShare(candidateStakingAddress.address)).to.equal(share); + + const newOperator = ethers.Wallet.createRandom().address; + const stakingEpoch = await stakingHbbft.stakingEpoch(); + + await expect(stakingHbbft.connect(candidateStakingAddress).setNodeOperator(newOperator, share)) + .to.be.revertedWithCustomError(stakingHbbft, "OnlyOncePerEpoch") + .withArgs(stakingEpoch); + }); + + it('should not allow zero address and non-zero percent', async () => { + const { stakingHbbft, validatorSetHbbft } = await helpers.loadFixture(deployContractsFixture); + + await stakingHbbft.connect(candidateStakingAddress).addPool( + candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, + ZeroPublicKey, + ZeroIpAddress, + { value: minStake } + ); + + await stakingHbbft.setValidatorMockSetAddress(accounts[7].address); + await stakingHbbft.connect(accounts[7]).incrementStakingEpoch(); + await stakingHbbft.setValidatorMockSetAddress(await validatorSetHbbft.getAddress()); + + const operator = ethers.ZeroAddress; + const share = 1000; + + await expect(stakingHbbft.connect(candidateStakingAddress).setNodeOperator(operator, share)) + .to.be.revertedWithCustomError(stakingHbbft, "InvalidNodeOperatorConfiguration") + .withArgs(operator, share); + }); + + it('should not exceed max share percent', async () => { + const { stakingHbbft, validatorSetHbbft } = await helpers.loadFixture(deployContractsFixture); + + await stakingHbbft.connect(candidateStakingAddress).addPool( + candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, + ZeroPublicKey, + ZeroIpAddress, + { value: minStake } + ); + + await stakingHbbft.setValidatorMockSetAddress(accounts[7].address); + await stakingHbbft.connect(accounts[7]).incrementStakingEpoch(); + await stakingHbbft.setValidatorMockSetAddress(await validatorSetHbbft.getAddress()); + + const operator = ethers.Wallet.createRandom().address; + const share = 2001; + + await expect(stakingHbbft.connect(candidateStakingAddress).setNodeOperator(operator, share)) + .to.be.revertedWithCustomError(stakingHbbft, "InvalidNodeOperatorShare") + .withArgs(share); + }); + + it('should change pool node operator configuration', async () => { + const { stakingHbbft, validatorSetHbbft } = await helpers.loadFixture(deployContractsFixture); + + await stakingHbbft.connect(candidateStakingAddress).addPool( + candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, + ZeroPublicKey, + ZeroIpAddress, + { value: minStake } + ); + + await stakingHbbft.setValidatorMockSetAddress(accounts[7].address); + await stakingHbbft.connect(accounts[7]).incrementStakingEpoch(); + await stakingHbbft.setValidatorMockSetAddress(await validatorSetHbbft.getAddress()); + + const operator = ethers.Wallet.createRandom().address; + const share = 1950; + + await expect(stakingHbbft.connect(candidateStakingAddress).setNodeOperator(operator, share)) + .to.emit(stakingHbbft, "SetNodeOperator") + .withArgs(candidateStakingAddress.address, operator, share); + + expect(await stakingHbbft.poolNodeOperator(candidateStakingAddress.address)).to.equal(operator); + expect(await stakingHbbft.poolNodeOperatorShare(candidateStakingAddress.address)).to.equal(share); + }); + }); + describe('contract balance', async () => { before(async () => { candidateMiningAddress = accounts[7]; @@ -553,6 +753,8 @@ describe('StakingHbbft', () => { expect(await ethers.provider.getBalance(await stakingHbbft.getAddress())).to.be.equal(0n); await stakingHbbft.connect(candidateStakingAddress).addPool( candidateMiningAddress.address, + ethers.ZeroAddress, + 0n, ZeroPublicKey, ZeroIpAddress, { value: minStake } @@ -2002,160 +2204,436 @@ describe('StakingHbbft', () => { )).to.not.emit(stakingHbbft, "RestakeReward"); }); - it('should restake all rewards to validator without delegators', async () => { - const { - stakingHbbft, - blockRewardHbbft, - validatorSetHbbft, - candidateMinStake - } = await helpers.loadFixture(deployContractsFixture); + describe('without node operator', async () => { + it('should restake all rewards to validator without delegators', async () => { + const { + stakingHbbft, + blockRewardHbbft, + validatorSetHbbft, + candidateMinStake + } = await helpers.loadFixture(deployContractsFixture); - expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); + expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); - for (let i = 0; i < initialStakingAddresses.length; ++i) { - const pool = await ethers.getSigner(initialStakingAddresses[i]); - const mining = await ethers.getSigner(initialValidators[i]); + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); + const mining = await ethers.getSigner(initialValidators[i]); - await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); + await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); - const latestBlock = await ethers.provider.getBlock('latest'); - await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); + const latestBlock = await ethers.provider.getBlock('latest'); + await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); - expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); - } + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); + } - let systemSigner = await impersonateAcc(SystemAccountAddress); + let systemSigner = await impersonateAcc(SystemAccountAddress); - await blockRewardHbbft.connect(systemSigner).reward(true); - await blockRewardHbbft.connect(systemSigner).reward(true); + await blockRewardHbbft.connect(systemSigner).reward(true); + await blockRewardHbbft.connect(systemSigner).reward(true); - await helpers.stopImpersonatingAccount(SystemAccountAddress); + await helpers.stopImpersonatingAccount(SystemAccountAddress); - const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); - await helpers.time.increaseTo(fixedEpochEndTime + 1n); - await helpers.mine(1); + const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); + await helpers.time.increaseTo(fixedEpochEndTime + 1n); + await helpers.mine(1); - const deltaPotValue = ethers.parseEther('50'); - await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); - expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); + const deltaPotValue = ethers.parseEther('50'); + await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); + expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); - const validators = await validatorSetHbbft.getValidators(); - const potsShares = await blockRewardHbbft.getPotsShares(validators.length); + const validators = await validatorSetHbbft.getValidators(); + const potsShares = await blockRewardHbbft.getPotsShares(validators.length); - const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; - const poolReward = validatorRewards / BigInt(validators.length); + const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + const poolReward = validatorRewards / BigInt(validators.length); - systemSigner = await impersonateAcc(SystemAccountAddress); - await blockRewardHbbft.connect(systemSigner).reward(true); - await helpers.stopImpersonatingAccount(SystemAccountAddress); + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); - for (let i = 0; i < initialStakingAddresses.length; ++i) { - const pool = await ethers.getSigner(initialStakingAddresses[i]); + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); - expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake + poolReward); - } + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake + poolReward); + } + }); + + it('should restake delegators rewards according to stakes', async () => { + const { + stakingHbbft, + blockRewardHbbft, + validatorSetHbbft, + candidateMinStake, + } = await helpers.loadFixture(deployContractsFixture); + + expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); + + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); + const mining = await ethers.getSigner(initialValidators[i]); + + await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); + + const latestBlock = await ethers.provider.getBlock('latest'); + await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); + + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); + } + + let systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + interface StakeRecord { + delegator: string; + pool: string; + stake: bigint; + } + + const delegators = accounts.slice(15, 20); + const stakeRecords = new Array(); + const poolTotalStakes = new Map(); + + for (const _pool of initialStakingAddresses) { + let _poolTotalStake = candidateMinStake; + + // first delegator will stake minimum, 2nd = 2x, 3rd = 3x .... + let stake = BigInt(0); + for (const _delegator of delegators) { + + stake += minStakeDelegators; + stakeRecords.push({ delegator: _delegator.address, pool: _pool, stake: stake }); + + _poolTotalStake += stake; + + await stakingHbbft.connect(_delegator).stake(_pool, { value: stake }); + expect(await stakingHbbft.stakeAmount(_pool, _delegator.address)).to.equal(stake); + } + + poolTotalStakes.set(_pool, _poolTotalStake); + + expect(await stakingHbbft.stakeAmountTotal(_pool)).to.be.eq(_poolTotalStake); + } + + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); + await helpers.time.increaseTo(fixedEpochEndTime + 1n); + await helpers.mine(1); + + const epoch = await stakingHbbft.stakingEpoch(); + + const deltaPotValue = ethers.parseEther('10'); + await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); + expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); + + const validators = await validatorSetHbbft.getValidators(); + const potsShares = await blockRewardHbbft.getPotsShares(validators.length); + + const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + const poolReward = validatorRewards / BigInt(validators.length); + + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + const validatorFixedRewardPercent = await blockRewardHbbft.validatorMinRewardPercent(epoch); + + for (const _stakeRecord of stakeRecords) { + const validatorFixedReward = poolReward * validatorFixedRewardPercent / 100n; + const rewardsToDistribute = poolReward - validatorFixedReward; + + const poolTotalStake = poolTotalStakes.get(_stakeRecord.pool)!; + + const validatorShare = validatorFixedReward + rewardsToDistribute * candidateMinStake / poolTotalStake; + const delegatorShare = rewardsToDistribute * _stakeRecord.stake / poolTotalStake; + + expect( + await stakingHbbft.stakeAmount(_stakeRecord.pool, _stakeRecord.pool) + ).to.be.closeTo(candidateMinStake + validatorShare, 100n); + + expect( + await stakingHbbft.stakeAmount(_stakeRecord.pool, _stakeRecord.delegator) + ).to.be.closeTo(_stakeRecord.stake + delegatorShare, 100n); + } + }); }); - it('should restake delegators rewards according to stakes', async () => { - const { - stakingHbbft, - blockRewardHbbft, - validatorSetHbbft, - candidateMinStake, - } = await helpers.loadFixture(deployContractsFixture); + describe('with node operator', async () => { + it('should not distribute to node operator with 0% share', async () => { + const { + stakingHbbft, + blockRewardHbbft, + validatorSetHbbft, + candidateMinStake + } = await helpers.loadFixture(deployContractsFixture); - expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); + expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); - for (let i = 0; i < initialStakingAddresses.length; ++i) { - const pool = await ethers.getSigner(initialStakingAddresses[i]); - const mining = await ethers.getSigner(initialValidators[i]); + let poolOperators = new Map(); - await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); + const mining = await ethers.getSigner(initialValidators[i]); - const latestBlock = await ethers.provider.getBlock('latest'); - await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); + await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); - expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); - } + const latestBlock = await ethers.provider.getBlock('latest'); + await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); - let systemSigner = await impersonateAcc(SystemAccountAddress); - await blockRewardHbbft.connect(systemSigner).reward(true); - await helpers.stopImpersonatingAccount(SystemAccountAddress); + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); - interface StakeRecord { - delegator: string; - pool: string; - stake: bigint; - } + poolOperators.set(pool, ethers.Wallet.createRandom().address); + } + + let systemSigner = await impersonateAcc(SystemAccountAddress); - const delegators = accounts.slice(15, 20); - const stakeRecords = new Array(); - const poolTotalStakes = new Map(); + await blockRewardHbbft.connect(systemSigner).reward(true); + await blockRewardHbbft.connect(systemSigner).reward(true); - for (const _pool of initialStakingAddresses) { - let _poolTotalStake = candidateMinStake; + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + for (let [pool, operator] of poolOperators) { + await stakingHbbft.connect(pool).setNodeOperator(operator, 0n); + } - // first delegator will stake minimum, 2nd = 2x, 3rd = 3x .... - let stake = BigInt(0); - for (const _delegator of delegators) { + const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); + await helpers.time.increaseTo(fixedEpochEndTime + 1n); + await helpers.mine(1); - stake += minStakeDelegators; - stakeRecords.push({ delegator: _delegator.address, pool: _pool, stake: stake }); + const deltaPotValue = ethers.parseEther('50'); + await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); + expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); - _poolTotalStake += stake; + const validators = await validatorSetHbbft.getValidators(); + const potsShares = await blockRewardHbbft.getPotsShares(validators.length); - await stakingHbbft.connect(_delegator).stake(_pool, { value: stake }); - expect(await stakingHbbft.stakeAmount(_pool, _delegator.address)).to.equal(stake); + const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + const poolReward = validatorRewards / BigInt(validators.length); + + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); + + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake + poolReward); + } + }); + + it('should include node operators in reward distribution', async () => { + const { + stakingHbbft, + blockRewardHbbft, + validatorSetHbbft, + candidateMinStake + } = await helpers.loadFixture(deployContractsFixture); + + interface NodeOperatorConfig { + operator: string; + share: bigint; } - poolTotalStakes.set(_pool, _poolTotalStake); + interface StakeRecord { + pool: HardhatEthersSigner; + delegator: string; + stake: bigint; + } - expect(await stakingHbbft.stakeAmountTotal(_pool)).to.be.eq(_poolTotalStake); - } + expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); - systemSigner = await impersonateAcc(SystemAccountAddress); - await blockRewardHbbft.connect(systemSigner).reward(true); - await helpers.stopImpersonatingAccount(SystemAccountAddress); + let poolOperators = new Map(); - const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); - await helpers.time.increaseTo(fixedEpochEndTime + 1n); - await helpers.mine(1); + for (let i = 0; i < initialStakingAddresses.length; ++i) { + const pool = await ethers.getSigner(initialStakingAddresses[i]); + const mining = await ethers.getSigner(initialValidators[i]); - const epoch = await stakingHbbft.stakingEpoch(); + expect(await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake })); - const deltaPotValue = ethers.parseEther('10'); - await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); - expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); + const latestBlock = await ethers.provider.getBlock('latest'); + await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); - const validators = await validatorSetHbbft.getValidators(); - const potsShares = await blockRewardHbbft.getPotsShares(validators.length); + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); - const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; - const poolReward = validatorRewards / BigInt(validators.length); + poolOperators.set( + pool.address, + { + operator: ethers.Wallet.createRandom().address, + share: BigInt(200 * (i + 1)), + } + ); + } - systemSigner = await impersonateAcc(SystemAccountAddress); - await blockRewardHbbft.connect(systemSigner).reward(true); - await helpers.stopImpersonatingAccount(SystemAccountAddress); + const delegators = accounts.slice(16, 21); + const stakeRecords = new Array(); + const poolTotalStakes = new Map(); - const validatorFixedRewardPercent = await blockRewardHbbft.validatorMinRewardPercent(epoch); + for (const _pool of initialStakingAddresses) { + let _poolTotalStake = candidateMinStake; - for (const _stakeRecord of stakeRecords) { - const validatorFixedReward = poolReward * validatorFixedRewardPercent / 100n; - const rewardsToDistribute = poolReward - validatorFixedReward; + // first delegator will stake minimum, 2nd = 2x, 3rd = 3x .... + let stake = BigInt(0); + for (const _delegator of delegators) { - const poolTotalStake = poolTotalStakes.get(_stakeRecord.pool)!; + stake += minStakeDelegators; + stakeRecords.push({ + delegator: _delegator.address, + pool: await ethers.getSigner(_pool), + stake: stake + }); - const validatorShare = validatorFixedReward + rewardsToDistribute * candidateMinStake / poolTotalStake; - const delegatorShare = rewardsToDistribute * _stakeRecord.stake / poolTotalStake; + _poolTotalStake += stake; - expect( - await stakingHbbft.stakeAmount(_stakeRecord.pool, _stakeRecord.pool) - ).to.be.closeTo(candidateMinStake + validatorShare, 100n); + expect(await stakingHbbft.connect(_delegator).stake(_pool, { value: stake })); + expect(await stakingHbbft.stakeAmount(_pool, _delegator.address)).to.equal(stake); + } - expect( - await stakingHbbft.stakeAmount(_stakeRecord.pool, _stakeRecord.delegator) - ).to.be.closeTo(_stakeRecord.stake + delegatorShare, 100n); - } + poolTotalStakes.set(_pool, _poolTotalStake); + + expect(await stakingHbbft.stakeAmountTotal(_pool)).to.be.eq(_poolTotalStake); + } + + let systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + for (let [pool, cfg] of poolOperators) { + const poolSigner = await ethers.getSigner(pool); + expect(await stakingHbbft.connect(poolSigner).setNodeOperator(cfg.operator, cfg.share)); + } + + const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); + await helpers.time.increaseTo(fixedEpochEndTime + 1n); + await helpers.mine(1); + + const deltaPotValue = ethers.parseEther('50'); + await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); + expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); + + const validators = await validatorSetHbbft.getValidators(); + const potsShares = await blockRewardHbbft.getPotsShares(validators.length); + + const validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + const poolReward = validatorRewards / BigInt(validators.length); + + const epoch = await stakingHbbft.stakingEpoch(); + const validatorFixedRewardPercent = await blockRewardHbbft.validatorMinRewardPercent(epoch); + + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + for (const _stakeRecord of stakeRecords) { + const nodeOperatorCfg = poolOperators.get(_stakeRecord.pool.address)!; + + const validatorFixedReward = poolReward * validatorFixedRewardPercent / 100n; + const rewardsToDistribute = poolReward - validatorFixedReward; + const nodeOperatorShare = poolReward * nodeOperatorCfg.share / 10000n; + + const poolTotalStake = poolTotalStakes.get(_stakeRecord.pool.address)!; + + const validatorShare = validatorFixedReward + - nodeOperatorShare + + rewardsToDistribute * candidateMinStake / poolTotalStake; + const delegatorShare = rewardsToDistribute * _stakeRecord.stake / poolTotalStake; + + expect( + await stakingHbbft.stakeAmount(_stakeRecord.pool.address, _stakeRecord.pool.address) + ).to.be.closeTo(candidateMinStake + validatorShare, 100n); + + expect( + await stakingHbbft.stakeAmount(_stakeRecord.pool.address, _stakeRecord.delegator) + ).to.be.closeTo(_stakeRecord.stake + delegatorShare, 100n); + + expect( + await stakingHbbft.stakeAmount(_stakeRecord.pool.address, nodeOperatorCfg.operator) + ).to.be.equal(nodeOperatorShare); + } + }); + + it('should send operator share to new address if it was changed', async () => { + const { + stakingHbbft, + blockRewardHbbft, + validatorSetHbbft, + candidateMinStake + } = await helpers.loadFixture(deployContractsFixture); + + expect(await ethers.provider.getBalance(await blockRewardHbbft.getAddress())).to.be.equal(0n); + + const pool = await ethers.getSigner(initialStakingAddresses[0]); + const mining = await ethers.getSigner(initialValidators[0]); + const nodeOperator = ethers.Wallet.createRandom().address; + const nodeOperatorShare = 2000n; + + await stakingHbbft.connect(pool).stake(pool.address, { value: candidateMinStake }); + const latestBlock = await ethers.provider.getBlock('latest'); + await validatorSetHbbft.connect(mining).announceAvailability(latestBlock!.number, latestBlock!.hash!); + expect(await stakingHbbft.stakeAmountTotal(pool.address)).to.be.eq(candidateMinStake); + + let systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + // set node operator + await stakingHbbft.connect(pool).setNodeOperator(nodeOperator, nodeOperatorShare); + + const fixedEpochEndTime = await stakingHbbft.stakingFixedEpochEndTime(); + await helpers.time.increaseTo(fixedEpochEndTime + 1n); + await helpers.mine(1); + + const deltaPotValue = ethers.parseEther('50'); + await blockRewardHbbft.addToDeltaPot({ value: deltaPotValue }); + expect(await blockRewardHbbft.deltaPot()).to.be.equal(deltaPotValue); + + const validators = await validatorSetHbbft.getValidators(); + let potsShares = await blockRewardHbbft.getPotsShares(validators.length); + + let validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + let poolReward = validatorRewards; + + let poolTotalStake = await stakingHbbft.stakeAmountTotal(pool.address); + + // distribute epoch rewards, so node operator will get shares + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + // node operator should get all of the fixed validator rewards; + let expectedOperatorStake = poolReward * nodeOperatorShare / 10000n; + let expectedValidatorStake = candidateMinStake + (poolReward - expectedOperatorStake) * candidateMinStake / poolTotalStake; + + expect(await stakingHbbft.stakeAmount(pool.address, nodeOperator)).to.equal(expectedOperatorStake); + expect(await stakingHbbft.stakeAmount(pool.address, pool.address)).to.equal(expectedValidatorStake); + + const newOperator = ethers.Wallet.createRandom(); + const oldOperatorStake = await stakingHbbft.stakeAmount(pool.address, nodeOperator); + const prevValidatorStake = await stakingHbbft.stakeAmount(pool.address, pool.address); + + await stakingHbbft.connect(pool).setNodeOperator(newOperator, nodeOperatorShare); + + systemSigner = await impersonateAcc(SystemAccountAddress); + await blockRewardHbbft.connect(systemSigner).reward(true); + await helpers.stopImpersonatingAccount(SystemAccountAddress); + + potsShares = await blockRewardHbbft.getPotsShares(validators.length); + validatorRewards = potsShares.totalRewards - potsShares.governancePotAmount; + poolReward = validatorRewards; + + poolTotalStake = await stakingHbbft.stakeAmountTotal(pool.address); + + const newOperatorStake = poolReward * nodeOperatorShare / 10000n; + const expectedOldOperatorStake = oldOperatorStake + (poolReward - newOperatorStake) * oldOperatorStake / poolTotalStake; + expectedValidatorStake = prevValidatorStake + (poolReward - newOperatorStake) * prevValidatorStake / poolTotalStake; + + expect(await stakingHbbft.stakeAmount(pool.address, newOperator)).to.equal(newOperatorStake); + expect(await stakingHbbft.stakeAmount(pool.address, nodeOperator)).to.equal(expectedOldOperatorStake); + expect(await stakingHbbft.stakeAmount(pool.address, pool.address)).to.equal(expectedValidatorStake); + }); }); }); diff --git a/test/ValidatorSetHbbft.ts b/test/ValidatorSetHbbft.ts index 52835e4..fb7a64d 100644 --- a/test/ValidatorSetHbbft.ts +++ b/test/ValidatorSetHbbft.ts @@ -839,6 +839,8 @@ describe('ValidatorSetHbbft', () => { const stakeAmount = stakeUnit * BigInt(i + 1); await stakingHbbft.connect(await ethers.getSigner(stakingAddresses[i])).addPool( miningAddresses[i], + ethers.ZeroAddress, + 0n, ethers.zeroPadBytes("0x00", 64), ethers.zeroPadBytes("0x00", 16), { value: stakeAmount }