From 67e94ddcec89e00d6dc69b7c3e5ebb616e04d6e3 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 26 Nov 2024 10:01:17 +0100 Subject: [PATCH 01/10] Add Ownable package for contants manager --- contracts/sfc/ConstantsManager.sol | 8 +++----- package-lock.json | 4 ++-- package.json | 1 + 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/contracts/sfc/ConstantsManager.sol b/contracts/sfc/ConstantsManager.sol index 1c41030..0e23abc 100644 --- a/contracts/sfc/ConstantsManager.sol +++ b/contracts/sfc/ConstantsManager.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.27; -import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Decimal} from "../common/Decimal.sol"; /** * @custom:security-contact security@fantom.foundation */ -contract ConstantsManager is OwnableUpgradeable { +contract ConstantsManager is Ownable { // Minimum amount of stake for a validator, i.e., 500000 FTM uint256 public minSelfStake; // Maximum ratio of delegations a validator can have, say, 15 times of self-stake @@ -47,9 +47,7 @@ contract ConstantsManager is OwnableUpgradeable { */ error ValueTooLarge(); - constructor(address owner) initializer { - __Ownable_init(owner); - } + constructor(address owner) Ownable(owner) {} function updateMinSelfStake(uint256 v) external virtual onlyOwner { if (v < 100000 * 1e18) { diff --git a/package-lock.json b/package-lock.json index 28bd9ae..3811c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.5-rc.1", "license": "MIT", "dependencies": { + "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", "dotenv": "^16.0.3" }, @@ -1618,8 +1619,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.1.0.tgz", "integrity": "sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@openzeppelin/contracts-upgradeable": { "version": "5.1.0", diff --git a/package.json b/package.json index 975e686..16df1ff 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "typescript-eslint": "^8.8.0" }, "dependencies": { + "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", "dotenv": "^16.0.3" } From 374264568596eecc4d9de3ac40d0597041134a0b Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 26 Nov 2024 12:27:29 +0100 Subject: [PATCH 02/10] Add collection of failed treasury fees --- contracts/sfc/SFC.sol | 31 +++++++++- contracts/test/FailingReceiver.sol | 9 +++ test/SFC.ts | 92 ++++++++++++++++++++++++------ test/helpers/BlockchainNode.ts | 12 ++-- 4 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 contracts/test/FailingReceiver.sol diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 5b97ec3..703aa3d 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.27; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {Decimal} from "../common/Decimal.sol"; import {NodeDriverAuth} from "./NodeDriverAuth.sol"; import {ConstantsManager} from "./ConstantsManager.sol"; @@ -13,7 +14,7 @@ import {Version} from "../version/Version.sol"; * @notice The SFC maintains a list of validators and delegators and distributes rewards to them. * @custom:security-contact security@fantom.foundation */ -contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { +contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, Version { uint256 internal constant OK_STATUS = 0; uint256 internal constant WITHDRAWN_BIT = 1; uint256 internal constant OFFLINE_BIT = 1 << 3; @@ -50,6 +51,9 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { // total stake of active (OK_STATUS) validators (total weight) uint256 public totalActiveStake; + // unresolved fees that failed to be send to the treasury + uint256 public unresolvedTreasuryFees; + // delegator => validator ID => stashed rewards (to be claimed/restaked) mapping(address delegator => mapping(uint256 validatorID => uint256 stashedRewards)) internal _rewardsStash; @@ -190,6 +194,10 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { error ValidatorNotSlashed(); error RefundRatioTooHigh(); + // treasury + error TreasuryNotSet(); + error NoUnresolvedTreasuryFees(); + event DeactivatedValidator(uint256 indexed validatorID, uint256 deactivatedEpoch, uint256 deactivatedTime); event ChangedValidatorStatus(uint256 indexed validatorID, uint256 status); event CreatedValidator( @@ -207,6 +215,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { event UpdatedSlashingRefundRatio(uint256 indexed validatorID, uint256 refundRatio); event RefundedSlashedLegacyDelegation(address indexed delegator, uint256 indexed validatorID, uint256 amount); event AnnouncedRedirection(address indexed from, address indexed to); + event TreasuryFeesResolved(uint256 amount); modifier onlyDriver() { if (!isNode(msg.sender)) { @@ -226,6 +235,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { ) external initializer { __Ownable_init(owner); __UUPSUpgradeable_init(); + __ReentrancyGuard_init(); currentSealedEpoch = sealedEpoch; node = NodeDriverAuth(nodeDriver); c = ConstantsManager(_c); @@ -419,6 +429,22 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } } + /// Resolve failed treasury transfers and send the unresolved fees to the treasury address. + function resolveTreasuryFees() external nonReentrant { + if (treasuryAddress == address(0)) { + revert TreasuryNotSet(); + } + if (unresolvedTreasuryFees == 0) { + revert NoUnresolvedTreasuryFees(); + } + (bool success, ) = treasuryAddress.call{value: unresolvedTreasuryFees, gas: 1000000}(""); + if (!success) { + revert TransferFailed(); + } + emit TreasuryFeesResolved(unresolvedTreasuryFees); + unresolvedTreasuryFees = 0; + } + /// burnFTM allows SFC to burn an arbitrary amount of FTM tokens. function burnFTM(uint256 amount) external onlyOwner { _burnFTM(amount); @@ -909,6 +935,9 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { if (!success) { // ignore treasury transfer failure // the treasury failure must not endanger the epoch sealing + + // store the unresolved treasury fees to be resolved later + unresolvedTreasuryFees += feeShare; } } } diff --git a/contracts/test/FailingReceiver.sol b/contracts/test/FailingReceiver.sol new file mode 100644 index 0000000..632069a --- /dev/null +++ b/contracts/test/FailingReceiver.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +contract FailingReceiver { + // Fallback function to reject any received Ether + receive() external payable { + revert("Forced transfer failure"); + } +} diff --git a/test/SFC.ts b/test/SFC.ts index c3351c3..7c72c77 100644 --- a/test/SFC.ts +++ b/test/SFC.ts @@ -644,10 +644,10 @@ describe('SFC', () => { }); it('Should succeed and seal epochs', async function () { - const validatorsMetrics: Map = new Map(); + const validatorsMetrics: Map = new Map(); const validatorIDs = await this.sfc.lastValidatorID(); - for (let i = 0; i < validatorIDs; i++) { + for (let i = 1n; i <= validatorIDs; i++) { validatorsMetrics.set(i, { offlineTime: 0, offlineBlocks: 0, @@ -661,8 +661,8 @@ describe('SFC', () => { const offlineBlocks = []; const uptimes = []; const originatedTxsFees = []; - for (let i = 0; i < validatorIDs; i++) { - allValidators.push(i + 1); + for (let i = 1n; i <= validatorIDs; i++) { + allValidators.push(i); offlineTimes.push(validatorsMetrics.get(i)!.offlineTime); offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks); uptimes.push(validatorsMetrics.get(i)!.uptime); @@ -674,11 +674,69 @@ describe('SFC', () => { await this.sfc.sealEpochValidators(allValidators); }); + describe('Treasury', () => { + it('Should revert when treasury is not set', async function () { + await expect(this.sfc.resolveTreasuryFees()).to.be.revertedWithCustomError(this.sfc, 'TreasuryNotSet'); + }); + + it('Should revert when no unresolved treasury fees are available', async function () { + const treasury = ethers.Wallet.createRandom(); + await this.sfc.connect(this.owner).updateTreasuryAddress(treasury); + await expect(this.sfc.resolveTreasuryFees()).to.be.revertedWithCustomError( + this.sfc, + 'NoUnresolvedTreasuryFees', + ); + }); + + it('Should succeed and resolve treasury fees', async function () { + // set treasury as failing receiver to trigger treasury fee accumulation + const failingReceiver = await ethers.deployContract('FailingReceiver'); + await this.sfc.connect(this.owner).updateTreasuryAddress(failingReceiver); + + // set validators metrics and their fees + const validatorsMetrics: Map = new Map(); + const validatorIDs = await this.sfc.lastValidatorID(); + for (let i = 1n; i <= validatorIDs; i++) { + validatorsMetrics.set(i, { + offlineTime: 0, + offlineBlocks: 0, + uptime: 24 * 60 * 60, + originatedTxsFee: ethers.parseEther('100'), + }); + } + + // seal epoch to trigger fees calculation and distribution + await this.blockchainNode.sealEpoch(24 * 60 * 60, validatorsMetrics); + + const fees = + (validatorIDs * ethers.parseEther('100') * (await this.constants.treasuryFeeShare())) / BigInt(1e18); + expect(await this.sfc.unresolvedTreasuryFees()).to.equal(fees); + + // update treasury to a valid receiver + const treasury = ethers.Wallet.createRandom(); + await this.sfc.connect(this.owner).updateTreasuryAddress(treasury); + + // set sfc some balance to cover treasury fees + // the funds cannot be sent directly as it rejects any incoming transfers + await ethers.provider.send('hardhat_setBalance', [ + await this.sfc.getAddress(), + ethers.toBeHex(ethers.parseEther('1000')), + ]); + + // resolve treasury fees + const tx = await this.sfc.resolveTreasuryFees(); + await expect(tx).to.emit(this.sfc, 'TreasuryFeesResolved').withArgs(fees); + await expect(tx).to.changeEtherBalance(treasury, fees); + await expect(tx).to.changeEtherBalance(this.sfc, -fees); + expect(await this.sfc.unresolvedTreasuryFees()).to.equal(0); + }); + }); + it('Should succeed and seal epoch on Validators', async function () { - const validatorsMetrics: Map = new Map(); + const validatorsMetrics: Map = new Map(); const validatorIDs = await this.sfc.lastValidatorID(); - for (let i = 0; i < validatorIDs; i++) { + for (let i = 1n; i <= validatorIDs; i++) { validatorsMetrics.set(i, { offlineTime: 0, offlineBlocks: 0, @@ -692,8 +750,8 @@ describe('SFC', () => { const offlineBlocks = []; const uptimes = []; const originatedTxsFees = []; - for (let i = 0; i < validatorIDs; i++) { - allValidators.push(i + 1); + for (let i = 1n; i <= validatorIDs; i++) { + allValidators.push(i); offlineTimes.push(validatorsMetrics.get(i)!.offlineTime); offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks); uptimes.push(validatorsMetrics.get(i)!.uptime); @@ -746,10 +804,10 @@ describe('SFC', () => { }); it('Should revert when calling sealEpoch if not NodeDriver', async function () { - const validatorsMetrics: Map = new Map(); + const validatorsMetrics: Map = new Map(); const validatorIDs = await this.sfc.lastValidatorID(); - for (let i = 0; i < validatorIDs; i++) { + for (let i = 1n; i <= validatorIDs; i++) { validatorsMetrics.set(i, { offlineTime: 0, offlineBlocks: 0, @@ -763,8 +821,8 @@ describe('SFC', () => { const offlineBlocks = []; const uptimes = []; const originatedTxsFees = []; - for (let i = 0; i < validatorIDs; i++) { - allValidators.push(i + 1); + for (let i = 1n; i <= validatorIDs; i++) { + allValidators.push(i); offlineTimes.push(validatorsMetrics.get(i)!.offlineTime); offlineBlocks.push(validatorsMetrics.get(i)!.offlineBlocks); uptimes.push(validatorsMetrics.get(i)!.uptime); @@ -982,7 +1040,7 @@ describe('SFC', () => { // 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)]]), + new Map([[this.validatorId, new ValidatorMetrics(0, 0, 100, 0n)]]), ); expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( 1000000000000000000n, @@ -991,7 +1049,7 @@ describe('SFC', () => { // 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)]]), + new Map([[this.validatorId, new ValidatorMetrics(0, 0, 20, 0n)]]), ); expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( 600000000000000000n, @@ -1000,7 +1058,7 @@ describe('SFC', () => { // 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)]]), + new Map([[this.validatorId, new ValidatorMetrics(0, 0, 30, 0n)]]), ); expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( 500000000000000000n, @@ -1010,7 +1068,7 @@ describe('SFC', () => { for (let i = 0; i < 10; i++) { await this.blockchainNode.sealEpoch( 100, - new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 50, 0n)]]), + new Map([[this.validatorId, new ValidatorMetrics(0, 0, 50, 0n)]]), ); expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( 500000000000000000n, @@ -1020,7 +1078,7 @@ describe('SFC', () => { // (50 * 10 + 28) / 11 = 48 await this.blockchainNode.sealEpoch( 100, - new Map([[this.validatorId as number, new ValidatorMetrics(0, 0, 28, 0n)]]), + new Map([[this.validatorId, new ValidatorMetrics(0, 0, 28, 0n)]]), ); expect(await this.sfc.getEpochAverageUptime(await this.sfc.currentSealedEpoch(), this.validatorId)).to.equal( 480000000000000000n, diff --git a/test/helpers/BlockchainNode.ts b/test/helpers/BlockchainNode.ts index 2e9e669..91682fd 100644 --- a/test/helpers/BlockchainNode.ts +++ b/test/helpers/BlockchainNode.ts @@ -1,4 +1,4 @@ -import { SFCUnitTestI } from '../../typechain-types'; +import { UnitTestSFC } from '../../typechain-types'; import { TransactionResponse } from 'ethers'; import { ethers } from 'hardhat'; @@ -17,11 +17,11 @@ class ValidatorMetrics { } class BlockchainNode { - public readonly sfc: SFCUnitTestI; - public validatorWeights: Map; - public nextValidatorWeights: Map; + public readonly sfc: UnitTestSFC; + public validatorWeights: Map; + public nextValidatorWeights: Map; - constructor(sfc: SFCUnitTestI) { + constructor(sfc: UnitTestSFC) { this.sfc = sfc; this.validatorWeights = new Map(); this.nextValidatorWeights = new Map(); @@ -44,7 +44,7 @@ class BlockchainNode { } } - async sealEpoch(duration: number, validatorMetrics?: Map) { + async sealEpoch(duration: number, validatorMetrics?: Map) { const validatorIds = Array.from(this.validatorWeights.keys()); const nextValidatorIds = Array.from(this.nextValidatorWeights.keys()); From eeddd39557e38f259fe0719e881a3720ee58f5b9 Mon Sep 17 00:00:00 2001 From: mike Date: Tue, 26 Nov 2024 12:45:13 +0100 Subject: [PATCH 03/10] Add storage gaps into upgradeable contracts --- contracts/sfc/NodeDriver.sol | 2 ++ contracts/sfc/NodeDriverAuth.sol | 2 ++ contracts/sfc/SFC.sol | 2 ++ 3 files changed, 6 insertions(+) diff --git a/contracts/sfc/NodeDriver.sol b/contracts/sfc/NodeDriver.sol index bc1287d..8ff9e07 100644 --- a/contracts/sfc/NodeDriver.sol +++ b/contracts/sfc/NodeDriver.sol @@ -144,4 +144,6 @@ contract NodeDriver is OwnableUpgradeable, UUPSUpgradeable, INodeDriver { function sealEpochValidators(uint256[] calldata nextValidatorIDs) external onlyNode { backend.sealEpochValidators(nextValidatorIDs); } + + uint256[50] private __gap; } diff --git a/contracts/sfc/NodeDriverAuth.sol b/contracts/sfc/NodeDriverAuth.sol index 961dda6..eba4f24 100644 --- a/contracts/sfc/NodeDriverAuth.sol +++ b/contracts/sfc/NodeDriverAuth.sol @@ -186,4 +186,6 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { } return codeHash; } + + uint256[50] private __gap; } diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 703aa3d..b5bc864 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -1146,4 +1146,6 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, function _now() internal view virtual returns (uint256) { return block.timestamp; } + + uint256[50] private __gap; } From b3c273c5654c30d65b787ae8ebe285566f06fb95 Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Nov 2024 09:59:35 +0100 Subject: [PATCH 04/10] Apply Checks-Effects-Interactions pattern --- contracts/sfc/SFC.sol | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 703aa3d..b5d6a07 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.27; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; -import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {Decimal} from "../common/Decimal.sol"; import {NodeDriverAuth} from "./NodeDriverAuth.sol"; import {ConstantsManager} from "./ConstantsManager.sol"; @@ -14,7 +13,7 @@ import {Version} from "../version/Version.sol"; * @notice The SFC maintains a list of validators and delegators and distributes rewards to them. * @custom:security-contact security@fantom.foundation */ -contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, Version { +contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { uint256 internal constant OK_STATUS = 0; uint256 internal constant WITHDRAWN_BIT = 1; uint256 internal constant OFFLINE_BIT = 1 << 3; @@ -235,7 +234,6 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, ) external initializer { __Ownable_init(owner); __UUPSUpgradeable_init(); - __ReentrancyGuard_init(); currentSealedEpoch = sealedEpoch; node = NodeDriverAuth(nodeDriver); c = ConstantsManager(_c); @@ -430,19 +428,24 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, ReentrancyGuardUpgradeable, } /// Resolve failed treasury transfers and send the unresolved fees to the treasury address. - function resolveTreasuryFees() external nonReentrant { + function resolveTreasuryFees() external { if (treasuryAddress == address(0)) { revert TreasuryNotSet(); } if (unresolvedTreasuryFees == 0) { revert NoUnresolvedTreasuryFees(); } - (bool success, ) = treasuryAddress.call{value: unresolvedTreasuryFees, gas: 1000000}(""); + + // zero the fees before sending to prevent re-entrancy + uint256 fees = unresolvedTreasuryFees; + unresolvedTreasuryFees = 0; + + (bool success, ) = treasuryAddress.call{value: fees, gas: 1000000}(""); if (!success) { revert TransferFailed(); } - emit TreasuryFeesResolved(unresolvedTreasuryFees); - unresolvedTreasuryFees = 0; + + emit TreasuryFeesResolved(fees); } /// burnFTM allows SFC to burn an arbitrary amount of FTM tokens. From d1726a44837521f5fbe0cf8d54f897cdd985f82e Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Nov 2024 10:09:04 +0100 Subject: [PATCH 05/10] Add penalty into `Withdrawn` event --- contracts/sfc/SFC.sol | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 5fa1377..3adbc73 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -207,7 +207,13 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { ); event Delegated(address indexed delegator, uint256 indexed toValidatorID, uint256 amount); event Undelegated(address indexed delegator, uint256 indexed toValidatorID, uint256 indexed wrID, uint256 amount); - event Withdrawn(address indexed delegator, uint256 indexed toValidatorID, uint256 indexed wrID, uint256 amount); + event Withdrawn( + address indexed delegator, + uint256 indexed toValidatorID, + uint256 indexed wrID, + uint256 amount, + uint256 penalty + ); event ClaimedRewards(address indexed delegator, uint256 indexed toValidatorID, uint256 rewards); event RestakedRewards(address indexed delegator, uint256 indexed toValidatorID, uint256 rewards); event BurntFTM(uint256 amount); @@ -735,7 +741,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } _burnFTM(penalty); - emit Withdrawn(delegator, toValidatorID, wrID, amount); + emit Withdrawn(delegator, toValidatorID, wrID, amount - penalty, penalty); } /// Get highest epoch for which can be claimed rewards for the given validator. From 34f41086793609aae1838cb014a69743b798f746 Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Nov 2024 10:54:11 +0100 Subject: [PATCH 06/10] Add option to issue tokens --- contracts/sfc/ConstantsManager.sol | 8 ++++++++ contracts/sfc/NodeDriverAuth.sol | 5 +++++ contracts/sfc/SFC.sol | 9 +++++++++ test/SFC.ts | 24 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/contracts/sfc/ConstantsManager.sol b/contracts/sfc/ConstantsManager.sol index 0e23abc..8c35e2a 100644 --- a/contracts/sfc/ConstantsManager.sol +++ b/contracts/sfc/ConstantsManager.sol @@ -37,6 +37,10 @@ contract ConstantsManager is Ownable { // Zero to disable validators deactivation by this metric. uint64 public minAverageUptime; + // The address of the recipient that receives issued tokens + // as a counterparty to the burnt FTM tokens + address public issuedTokensRecipient; + /** * @dev Given value is too small */ @@ -177,4 +181,8 @@ contract ConstantsManager is Ownable { } minAverageUptime = v; } + + function updateIssuedTokensRecipient(address v) external virtual onlyOwner { + issuedTokensRecipient = v; + } } diff --git a/contracts/sfc/NodeDriverAuth.sol b/contracts/sfc/NodeDriverAuth.sol index eba4f24..bdbbb5b 100644 --- a/contracts/sfc/NodeDriverAuth.sol +++ b/contracts/sfc/NodeDriverAuth.sol @@ -89,6 +89,11 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { driver.setBalance(acc, address(acc).balance + diff); } + /// Issue tokens as a counterparty to burnt FTM tokens. + function issueTokens(address acc, uint256 diff) external onlySFC { + driver.setBalance(acc, address(acc).balance + diff); + } + /// Upgrade code of given contract by coping it from other deployed contract. /// Avoids setting code to an external address. function upgradeCode(address acc, address from) external onlyOwner { diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 3adbc73..42e946e 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -459,6 +459,15 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { _burnFTM(amount); } + /// Issue tokens to the issued tokens recipient as a counterparty to the burnt FTM tokens. + function issueTokens(uint256 amount) external onlyOwner { + if (c.issuedTokensRecipient() == address(0)) { + revert ZeroAddress(); + } + node.issueTokens(c.issuedTokensRecipient(), amount); + totalSupply += amount; + } + /// Update treasury address. function updateTreasuryAddress(address v) external onlyOwner { treasuryAddress = v; diff --git a/test/SFC.ts b/test/SFC.ts index 7c72c77..b3ea8d6 100644 --- a/test/SFC.ts +++ b/test/SFC.ts @@ -131,6 +131,30 @@ describe('SFC', () => { }); }); + describe('Issue tokens', () => { + it('Should revert when not owner', async function () { + await expect(this.sfc.connect(this.user).issueTokens(ethers.parseEther('100'))).to.be.revertedWithCustomError( + this.sfc, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when recipient is not set', async function () { + await expect(this.sfc.connect(this.owner).issueTokens(ethers.parseEther('100'))).to.be.revertedWithCustomError( + this.sfc, + 'ZeroAddress', + ); + }); + + it('Should succeed and issue tokens', async function () { + await this.constants.updateIssuedTokensRecipient(this.user); + const supply = await this.sfc.totalSupply(); + const amount = ethers.parseEther('100'); + await this.sfc.connect(this.owner).issueTokens(amount); + expect(await this.sfc.totalSupply()).to.equal(supply + amount); + }); + }); + describe('Create validator', () => { const validatorsFixture = async () => { const validatorPubKey = From 56d591e75dbfbd2059edc36fcad4051c66119072 Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Nov 2024 13:00:31 +0100 Subject: [PATCH 07/10] Update Withdrawn event in interface --- contracts/interfaces/ISFC.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/ISFC.sol b/contracts/interfaces/ISFC.sol index c940a52..5e48c31 100644 --- a/contracts/interfaces/ISFC.sol +++ b/contracts/interfaces/ISFC.sol @@ -15,7 +15,13 @@ interface ISFC { ); event Delegated(address indexed delegator, uint256 indexed toValidatorID, uint256 amount); event Undelegated(address indexed delegator, uint256 indexed toValidatorID, uint256 indexed wrID, uint256 amount); - event Withdrawn(address indexed delegator, uint256 indexed toValidatorID, uint256 indexed wrID, uint256 amount); + event Withdrawn( + address indexed delegator, + uint256 indexed toValidatorID, + uint256 indexed wrID, + uint256 amount, + uint256 penalty + ); event ClaimedRewards(address indexed delegator, uint256 indexed toValidatorID, uint256 rewards); event RestakedRewards(address indexed delegator, uint256 indexed toValidatorID, uint256 rewards); event BurntFTM(uint256 amount); From e36a4cde3140e3d0ce9a5fb88003fd23354d397e Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 27 Nov 2024 18:51:37 +0100 Subject: [PATCH 08/10] Remove redundant `issueTokens` method on `NodeDriverAuth` --- contracts/sfc/NodeDriverAuth.sol | 8 -------- contracts/sfc/SFC.sol | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/contracts/sfc/NodeDriverAuth.sol b/contracts/sfc/NodeDriverAuth.sol index bdbbb5b..94383cd 100644 --- a/contracts/sfc/NodeDriverAuth.sol +++ b/contracts/sfc/NodeDriverAuth.sol @@ -83,14 +83,6 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { /// Mint native token. To be used by SFC for minting validators rewards. function incBalance(address acc, uint256 diff) external onlySFC { - if (acc != address(sfc)) { - revert RecipientNotSFC(); - } - driver.setBalance(acc, address(acc).balance + diff); - } - - /// Issue tokens as a counterparty to burnt FTM tokens. - function issueTokens(address acc, uint256 diff) external onlySFC { driver.setBalance(acc, address(acc).balance + diff); } diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 42e946e..ef9b782 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -464,7 +464,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { if (c.issuedTokensRecipient() == address(0)) { revert ZeroAddress(); } - node.issueTokens(c.issuedTokensRecipient(), amount); + node.incBalance(c.issuedTokensRecipient(), amount); totalSupply += amount; } From 7c57e0ef4094a4ad4fdd3dedb4ff01f8db5ad03e Mon Sep 17 00:00:00 2001 From: mike Date: Thu, 28 Nov 2024 08:20:35 +0100 Subject: [PATCH 09/10] Disable initializers to be called directly --- contracts/sfc/NodeDriver.sol | 5 +++++ contracts/sfc/NodeDriverAuth.sol | 5 +++++ contracts/sfc/SFC.sol | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/contracts/sfc/NodeDriver.sol b/contracts/sfc/NodeDriver.sol index 8ff9e07..d0a5002 100644 --- a/contracts/sfc/NodeDriver.sol +++ b/contracts/sfc/NodeDriver.sol @@ -35,6 +35,11 @@ contract NodeDriver is OwnableUpgradeable, UUPSUpgradeable, INodeDriver { event UpdateNetworkVersion(uint256 version); event AdvanceEpochs(uint256 num); + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + /// Initialization is called only once, after the contract deployment. /// Because the contract code is written directly into genesis, constructor cannot be used. function initialize(address _backend, address _evmWriterAddress, address _owner) external initializer { diff --git a/contracts/sfc/NodeDriverAuth.sol b/contracts/sfc/NodeDriverAuth.sol index 94383cd..713dabd 100644 --- a/contracts/sfc/NodeDriverAuth.sol +++ b/contracts/sfc/NodeDriverAuth.sol @@ -21,6 +21,11 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { error DriverCodeHashMismatch(); error RecipientNotSFC(); + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + // Initialize NodeDriverAuth, NodeDriver and SFC in one call to allow fewer genesis transactions function initialize(address payable _sfc, address _driver, address _owner) external initializer { __Ownable_init(_owner); diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index ef9b782..159f91f 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -229,6 +229,11 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { _; } + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + /// Initialization is called only once, after the contract deployment. /// Because the contract code is written directly into genesis, constructor cannot be used. function initialize( From 4d29843536311891161c0f1bc72ad11e9bb08251 Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 13 Dec 2024 08:58:59 +0100 Subject: [PATCH 10/10] Update constants manager --- contracts/sfc/ConstantsManager.sol | 29 +- contracts/sfc/NetworkInitializer.sol | 12 +- contracts/test/UnitTestConstantsManager.sol | 8 - contracts/test/UnitTestSFC.sol | 2 - test/ConstantsManager.ts | 335 ++++++++++++++++++++ 5 files changed, 342 insertions(+), 44 deletions(-) create mode 100644 test/ConstantsManager.ts diff --git a/contracts/sfc/ConstantsManager.sol b/contracts/sfc/ConstantsManager.sol index 8c35e2a..1ee3cc3 100644 --- a/contracts/sfc/ConstantsManager.sol +++ b/contracts/sfc/ConstantsManager.sol @@ -26,8 +26,6 @@ contract ConstantsManager is Ownable { uint256 public baseRewardPerSecond; uint256 public offlinePenaltyThresholdBlocksNum; uint256 public offlinePenaltyThresholdTime; - 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. @@ -81,14 +79,14 @@ contract ConstantsManager is Ownable { } function updateBurntFeeShare(uint256 v) external virtual onlyOwner { - if (v > Decimal.unit() / 2) { + if (v + treasuryFeeShare > Decimal.unit()) { revert ValueTooLarge(); } burntFeeShare = v; } function updateTreasuryFeeShare(uint256 v) external virtual onlyOwner { - if (v > Decimal.unit() / 2) { + if (v + burntFeeShare > Decimal.unit()) { revert ValueTooLarge(); } treasuryFeeShare = v; @@ -115,9 +113,6 @@ contract ConstantsManager is Ownable { } function updateBaseRewardPerSecond(uint256 v) external virtual onlyOwner { - if (v < 0.5 * 1e18) { - revert ValueTooSmall(); - } if (v > 32 * 1e18) { revert ValueTooLarge(); } @@ -144,26 +139,6 @@ contract ConstantsManager is Ownable { offlinePenaltyThresholdBlocksNum = v; } - function updateTargetGasPowerPerSecond(uint256 v) external virtual onlyOwner { - if (v < 1000000) { - revert ValueTooSmall(); - } - if (v > 500000000) { - revert ValueTooLarge(); - } - targetGasPowerPerSecond = v; - } - - function updateGasPriceBalancingCounterweight(uint256 v) external virtual onlyOwner { - if (v < 100) { - revert ValueTooSmall(); - } - if (v > 10 * 86400) { - revert ValueTooLarge(); - } - gasPriceBalancingCounterweight = v; - } - function updateAverageUptimeEpochWindow(uint32 v) external virtual onlyOwner { if (v < 10) { // needs to be long enough to allow permissible downtime for validators maintenance diff --git a/contracts/sfc/NetworkInitializer.sol b/contracts/sfc/NetworkInitializer.sol index 15c7378..bcc5b76 100644 --- a/contracts/sfc/NetworkInitializer.sol +++ b/contracts/sfc/NetworkInitializer.sol @@ -25,18 +25,16 @@ contract NetworkInitializer { NodeDriverAuth(_auth).initialize(_sfc, _driver, _owner); ConstantsManager consts = new ConstantsManager(address(this)); - consts.updateMinSelfStake(500000 * 1e18); + consts.updateMinSelfStake(500_000 * 1e18); consts.updateMaxDelegatedRatio(16 * Decimal.unit()); consts.updateValidatorCommission((15 * Decimal.unit()) / 100); - consts.updateBurntFeeShare((20 * Decimal.unit()) / 100); - consts.updateTreasuryFeeShare((10 * Decimal.unit()) / 100); + consts.updateBurntFeeShare(0); + consts.updateTreasuryFeeShare((90 * Decimal.unit()) / 100); consts.updateWithdrawalPeriodEpochs(3); consts.updateWithdrawalPeriodTime(60 * 60 * 24 * 7); - consts.updateBaseRewardPerSecond(2668658453701531600); + consts.updateBaseRewardPerSecond(1_000); consts.updateOfflinePenaltyThresholdTime(5 days); - consts.updateOfflinePenaltyThresholdBlocksNum(1000); - consts.updateTargetGasPowerPerSecond(2000000); - consts.updateGasPriceBalancingCounterweight(3600); + consts.updateOfflinePenaltyThresholdBlocksNum(1_000); consts.updateAverageUptimeEpochWindow(100); consts.updateMinAverageUptime(0); // check disabled by default consts.transferOwnership(_owner); diff --git a/contracts/test/UnitTestConstantsManager.sol b/contracts/test/UnitTestConstantsManager.sol index 4ba2327..6cde2e0 100644 --- a/contracts/test/UnitTestConstantsManager.sol +++ b/contracts/test/UnitTestConstantsManager.sol @@ -14,15 +14,7 @@ contract UnitTestConstantsManager is ConstantsManager { baseRewardPerSecond = v; } - function updateGasPriceBalancingCounterweight(uint256 v) external override onlyOwner { - gasPriceBalancingCounterweight = v; - } - function updateOfflinePenaltyThresholdTime(uint256 v) external override onlyOwner { offlinePenaltyThresholdTime = v; } - - function updateTargetGasPowerPerSecond(uint256 v) external override onlyOwner { - targetGasPowerPerSecond = v; - } } diff --git a/contracts/test/UnitTestSFC.sol b/contracts/test/UnitTestSFC.sol index e45a54d..2d9192c 100644 --- a/contracts/test/UnitTestSFC.sol +++ b/contracts/test/UnitTestSFC.sol @@ -72,8 +72,6 @@ contract UnitTestNetworkInitializer { consts.updateBaseRewardPerSecond(6183414351851851852); consts.updateOfflinePenaltyThresholdTime(3 days); consts.updateOfflinePenaltyThresholdBlocksNum(1000); - consts.updateTargetGasPowerPerSecond(2000000); - consts.updateGasPriceBalancingCounterweight(6 * 60 * 60); consts.updateAverageUptimeEpochWindow(10); consts.updateMinAverageUptime(0); // check disabled by default consts.transferOwnership(_owner); diff --git a/test/ConstantsManager.ts b/test/ConstantsManager.ts new file mode 100644 index 0000000..50e2d61 --- /dev/null +++ b/test/ConstantsManager.ts @@ -0,0 +1,335 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { ConstantsManager } from '../typechain-types'; + +describe('ConstantsManager', () => { + const fixture = async () => { + const [owner, nonOwner] = await ethers.getSigners(); + const manager: ConstantsManager = await ethers.deployContract('ConstantsManager', [owner]); + + return { + owner, + nonOwner, + manager, + }; + }; + + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('Update min self-stake', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateMinSelfStake(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too small', async function () { + await expect( + this.manager.connect(this.owner).updateMinSelfStake(100_000n * BigInt(1e18) - 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooSmall'); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateMinSelfStake(10_000_000n * BigInt(1e18) + 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update min self-stake', async function () { + const newValue = 1_000_000n * BigInt(1e18); + await this.manager.connect(this.owner).updateMinSelfStake(newValue); + expect(await this.manager.minSelfStake()).to.equal(newValue); + }); + }); + + describe('Update max delegated ratio', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateMaxDelegatedRatio(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too small', async function () { + await expect( + this.manager.connect(this.owner).updateMaxDelegatedRatio(BigInt(1e18) - 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooSmall'); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateMaxDelegatedRatio(31n * BigInt(1e18) + 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update max delegated ratio', async function () { + const newValue = BigInt(1e18); + await this.manager.connect(this.owner).updateMaxDelegatedRatio(newValue); + expect(await this.manager.maxDelegatedRatio()).to.equal(newValue); + }); + }); + + describe('Update validator commission', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateValidatorCommission(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateValidatorCommission(BigInt(1e18) / 2n + 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update validator commission', async function () { + const newValue = BigInt(1e18) / 2n; + await this.manager.connect(this.owner).updateValidatorCommission(newValue); + expect(await this.manager.validatorCommission()).to.equal(newValue); + }); + }); + + describe('Update burnt fee share', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateBurntFeeShare(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too large', async function () { + // set treasury fee share to 60% + await this.manager.connect(this.owner).updateTreasuryFeeShare((BigInt(1e18) * 60n) / 100n); + + // set burnt fee share to 50% -> should revert because exceeds 100% + await expect( + this.manager.connect(this.owner).updateBurntFeeShare((BigInt(1e18) * 50n) / 100n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update burnt fee share', async function () { + const newValue = BigInt(1e18) / 2n; + await this.manager.connect(this.owner).updateBurntFeeShare(newValue); + expect(await this.manager.burntFeeShare()).to.equal(newValue); + }); + }); + + describe('Update treasury fee share', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateTreasuryFeeShare(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too large', async function () { + // set burnt fee share to 40% + await this.manager.connect(this.owner).updateBurntFeeShare((BigInt(1e18) * 40n) / 100n); + + // set treasury fee share to 61% -> should revert because exceeds 100% + await expect( + this.manager.connect(this.owner).updateTreasuryFeeShare((BigInt(1e18) * 61n) / 100n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update treasury fee share', async function () { + const newValue = BigInt(1e18) / 2n; + await this.manager.connect(this.owner).updateTreasuryFeeShare(newValue); + expect(await this.manager.treasuryFeeShare()).to.equal(newValue); + }); + }); + + describe('Update withdrawal period epochs', () => { + it('Should revert when not owner', async function () { + await expect( + this.manager.connect(this.nonOwner).updateWithdrawalPeriodEpochs(1000), + ).to.be.revertedWithCustomError(this.manager, 'OwnableUnauthorizedAccount'); + }); + + it('Should revert when value is too small', async function () { + await expect(this.manager.connect(this.owner).updateWithdrawalPeriodEpochs(1)).to.be.revertedWithCustomError( + this.manager, + 'ValueTooSmall', + ); + }); + + it('Should revert when value is too large', async function () { + await expect(this.manager.connect(this.owner).updateWithdrawalPeriodEpochs(101)).to.be.revertedWithCustomError( + this.manager, + 'ValueTooLarge', + ); + }); + + it('Should succeed and update withdrawal period epochs', async function () { + const newValue = 50; + await this.manager.connect(this.owner).updateWithdrawalPeriodEpochs(newValue); + expect(await this.manager.withdrawalPeriodEpochs()).to.equal(newValue); + }); + }); + + describe('Update withdrawal period time', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateWithdrawalPeriodTime(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too small', async function () { + await expect( + this.manager.connect(this.owner).updateWithdrawalPeriodTime(86_400 - 1), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooSmall'); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateWithdrawalPeriodTime(30 * 86_400 + 1), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update withdrawal period time', async function () { + const newValue = 86_400; + await this.manager.connect(this.owner).updateWithdrawalPeriodTime(newValue); + expect(await this.manager.withdrawalPeriodTime()).to.equal(newValue); + }); + }); + + describe('Update base reward per second', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateBaseRewardPerSecond(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateBaseRewardPerSecond(32n * BigInt(1e18) + 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update base reward per second', async function () { + const newValue = BigInt(1e18); + await this.manager.connect(this.owner).updateBaseRewardPerSecond(newValue); + expect(await this.manager.baseRewardPerSecond()).to.equal(newValue); + }); + }); + + describe('Update offline penalty threshold time', () => { + it('Should revert when not owner', async function () { + await expect( + this.manager.connect(this.nonOwner).updateOfflinePenaltyThresholdTime(1000), + ).to.be.revertedWithCustomError(this.manager, 'OwnableUnauthorizedAccount'); + }); + + it('Should revert when value is too small', async function () { + await expect( + this.manager.connect(this.owner).updateOfflinePenaltyThresholdTime(86_400 - 1), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooSmall'); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateOfflinePenaltyThresholdTime(10 * 86_400 + 1), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update offline penalty threshold time', async function () { + const newValue = 86_400; + await this.manager.connect(this.owner).updateOfflinePenaltyThresholdTime(newValue); + expect(await this.manager.offlinePenaltyThresholdTime()).to.equal(newValue); + }); + }); + + describe('Update offline penalty threshold blocks num', () => { + it('Should revert when not owner', async function () { + await expect( + this.manager.connect(this.nonOwner).updateOfflinePenaltyThresholdBlocksNum(1000), + ).to.be.revertedWithCustomError(this.manager, 'OwnableUnauthorizedAccount'); + }); + + it('Should revert when value is too small', async function () { + await expect( + this.manager.connect(this.owner).updateOfflinePenaltyThresholdBlocksNum(99), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooSmall'); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateOfflinePenaltyThresholdBlocksNum(1_000_001), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update offline penalty threshold blocks num', async function () { + const newValue = 500; + await this.manager.connect(this.owner).updateOfflinePenaltyThresholdBlocksNum(newValue); + expect(await this.manager.offlinePenaltyThresholdBlocksNum()).to.equal(newValue); + }); + }); + + describe('Update average uptime epoch window', () => { + it('Should revert when not owner', async function () { + await expect( + this.manager.connect(this.nonOwner).updateAverageUptimeEpochWindow(1000), + ).to.be.revertedWithCustomError(this.manager, 'OwnableUnauthorizedAccount'); + }); + + it('Should revert when value is too small', async function () { + await expect(this.manager.connect(this.owner).updateAverageUptimeEpochWindow(9)).to.be.revertedWithCustomError( + this.manager, + 'ValueTooSmall', + ); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateAverageUptimeEpochWindow(87_601), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update average uptime epoch window', async function () { + const newValue = 50; + await this.manager.connect(this.owner).updateAverageUptimeEpochWindow(newValue); + expect(await this.manager.averageUptimeEpochWindow()).to.equal(newValue); + }); + }); + + describe('Update min average uptime', () => { + it('Should revert when not owner', async function () { + await expect(this.manager.connect(this.nonOwner).updateMinAverageUptime(1000)).to.be.revertedWithCustomError( + this.manager, + 'OwnableUnauthorizedAccount', + ); + }); + + it('Should revert when value is too large', async function () { + await expect( + this.manager.connect(this.owner).updateMinAverageUptime((BigInt(1e18) * 9n) / 10n + 1n), + ).to.be.revertedWithCustomError(this.manager, 'ValueTooLarge'); + }); + + it('Should succeed and update min average uptime', async function () { + const newValue = 95; + await this.manager.connect(this.owner).updateMinAverageUptime(newValue); + expect(await this.manager.minAverageUptime()).to.equal(newValue); + }); + }); + + describe('Update issued tokens recipient', () => { + it('Should revert when not owner', async function () { + await expect( + this.manager.connect(this.nonOwner).updateIssuedTokensRecipient(this.nonOwner.address), + ).to.be.revertedWithCustomError(this.manager, 'OwnableUnauthorizedAccount'); + }); + + it('Should succeed and update issued tokens recipient', async function () { + await this.manager.connect(this.owner).updateIssuedTokensRecipient(this.nonOwner); + expect(await this.manager.issuedTokensRecipient()).to.equal(this.nonOwner); + }); + }); +});