From e48295048b82b5905c4895a46c11286fda6cb405 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 9 Nov 2024 08:16:49 +0100 Subject: [PATCH] Add average uptime calculation (#97) * Add average uptime for validators * Improve codestyle, unify data types * Move average calculation into a standalone func * Add deactivation procedure for minAverageUptime * Use Unit.decimal() and uint64 * Reintroduce remainder * Fix by review * Add unit test --------- Co-authored-by: Bernhard Scholz --- contracts/interfaces/ISFC.sol | 2 + contracts/sfc/ConstantsManager.sol | 26 ++++++++++ contracts/sfc/NetworkInitializer.sol | 2 + contracts/sfc/SFC.sol | 77 +++++++++++++++++++++++++++ contracts/test/UnitTestSFC.sol | 2 + test/SFC.ts | 78 ++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+) diff --git a/contracts/interfaces/ISFC.sol b/contracts/interfaces/ISFC.sol index f507325..39804c4 100644 --- a/contracts/interfaces/ISFC.sol +++ b/contracts/interfaces/ISFC.sol @@ -111,6 +111,8 @@ interface ISFC { function getEpochAccumulatedUptime(uint256 epoch, uint256 validatorID) external view returns (uint256); + function getEpochAverageUptime(uint256 epoch, uint256 validatorID) external view returns (uint32); + function getEpochAccumulatedOriginatedTxsFee(uint256 epoch, uint256 validatorID) external view returns (uint256); function getEpochOfflineTime(uint256 epoch, uint256 validatorID) external view returns (uint256); diff --git a/contracts/sfc/ConstantsManager.sol b/contracts/sfc/ConstantsManager.sol index 99774da..154d889 100644 --- a/contracts/sfc/ConstantsManager.sol +++ b/contracts/sfc/ConstantsManager.sol @@ -29,6 +29,14 @@ contract ConstantsManager is Ownable { uint256 public targetGasPowerPerSecond; uint256 public gasPriceBalancingCounterweight; + // The number of epochs to calculate the average uptime ratio from, acceptable bound [10, 87600]. + // Is also the minimum number of epochs necessary for deactivation of offline validators. + uint32 public averageUptimeEpochWindow; + + // Minimum average uptime ratio in fixed-point format; acceptable bounds [0,0.9]. + // Zero to disable validators deactivation by this metric. + uint64 public minAverageUptime; + /** * @dev Given value is too small */ @@ -153,4 +161,22 @@ contract ConstantsManager is Ownable { } gasPriceBalancingCounterweight = v; } + + function updateAverageUptimeEpochWindow(uint32 v) external virtual onlyOwner { + if (v < 10) { + // needs to be long enough to allow permissible downtime for validators maintenance + revert ValueTooSmall(); + } + if (v > 87600) { + revert ValueTooLarge(); + } + averageUptimeEpochWindow = v; + } + + function updateMinAverageUptime(uint64 v) external virtual onlyOwner { + if (v > ((Decimal.unit() * 9) / 10)) { + revert ValueTooLarge(); + } + minAverageUptime = v; + } } diff --git a/contracts/sfc/NetworkInitializer.sol b/contracts/sfc/NetworkInitializer.sol index 6e9857d..674fee2 100644 --- a/contracts/sfc/NetworkInitializer.sol +++ b/contracts/sfc/NetworkInitializer.sol @@ -37,6 +37,8 @@ contract NetworkInitializer { consts.updateOfflinePenaltyThresholdBlocksNum(1000); consts.updateTargetGasPowerPerSecond(2000000); consts.updateGasPriceBalancingCounterweight(3600); + consts.updateAverageUptimeEpochWindow(100); + consts.updateMinAverageUptime(0); // check disabled by default consts.transferOwnership(_owner); ISFC(_sfc).initialize(sealedEpoch, totalSupply, _auth, address(consts), _owner); diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index dc793f9..2c318c8 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -17,6 +17,7 @@ contract SFC is Initializable, Ownable, Version { uint256 internal constant OK_STATUS = 0; uint256 internal constant WITHDRAWN_BIT = 1; uint256 internal constant OFFLINE_BIT = 1 << 3; + uint256 internal constant OFFLINE_AVG_BIT = 1 << 4; uint256 internal constant DOUBLESIGN_BIT = 1 << 7; uint256 internal constant CHEATER_MASK = DOUBLESIGN_BIT; @@ -68,6 +69,16 @@ contract SFC is Initializable, Ownable, Version { // delegator => validator ID => current stake mapping(address delegator => mapping(uint256 validatorID => uint256 stake)) public getStake; + // data structure to compute average uptime for each active validator + struct AverageUptime { + // average uptime ratio as a value between 0 and 1e18 + uint64 averageUptime; + // remainder from the division in the average calculation + uint32 remainder; + // number of epochs in the average (at most averageUptimeEpochsWindow) + uint32 epochs; + } + struct EpochSnapshot { // validator ID => validator weight in the epoch mapping(uint256 => uint256) receivedStake; @@ -75,6 +86,8 @@ contract SFC is Initializable, Ownable, Version { mapping(uint256 => uint256) accumulatedRewardPerToken; // validator ID => accumulated online time mapping(uint256 => uint256) accumulatedUptime; + // validator ID => average uptime as a percentage + mapping(uint256 => AverageUptime) averageUptime; // validator ID => gas fees from txs originated by the validator mapping(uint256 => uint256) accumulatedOriginatedTxsFee; mapping(uint256 => uint256) offlineTime; @@ -288,6 +301,7 @@ contract SFC is Initializable, Ownable, Version { epochDuration = _now() - prevSnapshot.endTime; } _sealEpochRewards(epochDuration, snapshot, prevSnapshot, validatorIDs, uptimes, originatedTxsFee); + _sealEpochAverageUptime(epochDuration, snapshot, prevSnapshot, validatorIDs, uptimes); } currentSealedEpoch = currentEpoch(); @@ -520,6 +534,11 @@ contract SFC is Initializable, Ownable, Version { return getEpochSnapshot[epoch].accumulatedUptime[validatorID]; } + /// Get average uptime for a validator in a given epoch. + function getEpochAverageUptime(uint256 epoch, uint256 validatorID) public view returns (uint64) { + return getEpochSnapshot[epoch].averageUptime[validatorID].averageUptime; + } + /// Get accumulated originated txs fee for a validator in a given epoch. function getEpochAccumulatedOriginatedTxsFee(uint256 epoch, uint256 validatorID) public view returns (uint256) { return getEpochSnapshot[epoch].accumulatedOriginatedTxsFee[validatorID]; @@ -901,6 +920,64 @@ contract SFC is Initializable, Ownable, Version { } } + /// Seal epoch - recalculate average uptime time of validators + function _sealEpochAverageUptime( + uint256 epochDuration, + EpochSnapshot storage snapshot, + EpochSnapshot storage prevSnapshot, + uint256[] memory validatorIDs, + uint256[] memory uptimes + ) internal { + for (uint256 i = 0; i < validatorIDs.length; i++) { + uint256 validatorID = validatorIDs[i]; + // compute normalised uptime as a percentage in the fixed-point format + uint256 normalisedUptime = (uptimes[i] * Decimal.unit()) / epochDuration; + if (normalisedUptime > Decimal.unit()) { + normalisedUptime = Decimal.unit(); + } + AverageUptime memory previous = prevSnapshot.averageUptime[validatorID]; + AverageUptime memory current = _addElementIntoAverageUptime(uint64(normalisedUptime), previous); + snapshot.averageUptime[validatorID] = current; + + // remove validator if average uptime drops below min average uptime + // (by setting minAverageUptime to zero, this check is ignored) + if (current.averageUptime < c.minAverageUptime() && current.epochs >= c.averageUptimeEpochWindow()) { + _setValidatorDeactivated(validatorID, OFFLINE_AVG_BIT); + _syncValidator(validatorID, false); + } + } + } + + function _addElementIntoAverageUptime( + uint64 newValue, + AverageUptime memory prev + ) private view returns (AverageUptime memory) { + AverageUptime memory cur; + if (prev.epochs == 0) { + cur.averageUptime = newValue; // the only element for the average + cur.epochs = 1; + return cur; + } + + // the number of elements the average is calculated from + uint128 n = prev.epochs + 1; + // add new value into the average + uint128 tmp = (n - 1) * uint128(prev.averageUptime) + uint128(newValue) + prev.remainder; + + cur.averageUptime = uint64(tmp / n); + cur.remainder = uint32(tmp % n); + + if (cur.averageUptime > Decimal.unit()) { + cur.averageUptime = uint64(Decimal.unit()); + } + if (prev.epochs < c.averageUptimeEpochWindow()) { + cur.epochs = prev.epochs + 1; + } else { + cur.epochs = prev.epochs; + } + return cur; + } + /// Create a new validator. function _createValidator(address auth, bytes calldata pubkey) internal { uint256 validatorID = ++lastValidatorID; diff --git a/contracts/test/UnitTestSFC.sol b/contracts/test/UnitTestSFC.sol index 18c53fb..25a766a 100644 --- a/contracts/test/UnitTestSFC.sol +++ b/contracts/test/UnitTestSFC.sol @@ -75,6 +75,8 @@ contract UnitTestNetworkInitializer { consts.updateOfflinePenaltyThresholdBlocksNum(1000); consts.updateTargetGasPowerPerSecond(2000000); consts.updateGasPriceBalancingCounterweight(6 * 60 * 60); + consts.updateAverageUptimeEpochWindow(10); + consts.updateMinAverageUptime(0); // check disabled by default consts.transferOwnership(_owner); ISFC(_sfc).initialize(sealedEpoch, totalSupply, _auth, address(consts), _owner); diff --git a/test/SFC.ts b/test/SFC.ts index 07c9570..d90ee7f 100644 --- a/test/SFC.ts +++ b/test/SFC.ts @@ -951,4 +951,82 @@ describe('SFC', () => { await expect(this.sfc._syncValidator(33, false)).to.be.revertedWithCustomError(this.sfc, 'ValidatorNotExists'); }); }); + + describe('Average uptime calculation', () => { + const validatorsFixture = async function (this: Context) { + const [validator] = await ethers.getSigners(); + const pubkey = + '0xc000a2941866e485442aa6b17d67d77f8a6c4580bb556894cc1618473eff1e18203d8cce50b563cf4c75e408886079b8f067069442ed52e2ac9e556baa3f8fcc525f'; + const blockchainNode = new BlockchainNode(this.sfc); + + await this.sfc.rebaseTime(); + await this.sfc.enableNonNodeCalls(); + + await blockchainNode.handleTx( + await this.sfc.connect(validator).createValidator(pubkey, { value: ethers.parseEther('10') }), + ); + + const validatorId = await this.sfc.getValidatorID(validator); + + await blockchainNode.sealEpoch(0); + + return { + validatorId, + blockchainNode, + }; + }; + + beforeEach(async function () { + return Object.assign(this, await loadFixture(validatorsFixture.bind(this))); + }); + + it('Should calculate uptime correctly', async function () { + // validator online 100% of time in the first epoch => average 100% + await this.blockchainNode.sealEpoch( + 100, + new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 100, 0n)]]), + ); + expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( + 1000000000000000000n, + ); + + // validator online 20% of time in the second epoch => average 60% + await this.blockchainNode.sealEpoch( + 100, + new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 20, 0n)]]), + ); + expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( + 600000000000000000n, + ); + + // validator online 30% of time in the third epoch => average 50% + await this.blockchainNode.sealEpoch( + 100, + new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 30, 0n)]]), + ); + expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( + 500000000000000000n, + ); + + // fill the averaging window + for (let i = 0; i < 10; i++) { + await this.blockchainNode.sealEpoch( + 100, + new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 50, 0n)]]), + ); + expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( + 500000000000000000n, + ); + } + + // (50 * 10 + 28) / 11 = 48 + await this.blockchainNode.sealEpoch( + 100, + new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 28, 0n)]]), + ); + expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( + 480000000000000000n, + ); + }); + }); });