diff --git a/contracts/interfaces/ISFC.sol b/contracts/interfaces/ISFC.sol index c940a52..10813c7 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); @@ -121,6 +127,8 @@ interface ISFC { function getEpochEndBlock(uint256 epoch) external view returns (uint256); + function epochEndTime(uint256 epoch) external view returns (uint256); + function rewardsStash(address delegator, uint256 validatorID) external view returns (uint256); function createValidator(bytes calldata pubkey) external payable; diff --git a/contracts/interfaces/IStakeSubscriber.sol b/contracts/interfaces/IStakeSubscriber.sol new file mode 100644 index 0000000..a873173 --- /dev/null +++ b/contracts/interfaces/IStakeSubscriber.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +/** + * @title Stake Subscriber Interface + * @notice Used to recount votes from delegators in the governance contract + * @custom:security-contact security@fantom.foundation + */ +interface IStakeSubscriber { + function announceStakeChange(address delegator, address validator) external; +} diff --git a/contracts/sfc/ConstantsManager.sol b/contracts/sfc/ConstantsManager.sol index e282c69..ae6f87f 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 @@ -37,6 +37,10 @@ contract ConstantsManager is OwnableUpgradeable { // 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 */ @@ -47,15 +51,13 @@ 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) { + if (v < 100000 * Decimal.unit()) { revert ValueTooSmall(); } - if (v > 10000000 * 1e18) { + if (v > 10000000 * Decimal.unit()) { revert ValueTooLarge(); } minSelfStake = v; @@ -113,10 +115,10 @@ contract ConstantsManager is OwnableUpgradeable { } function updateBaseRewardPerSecond(uint256 v) external virtual onlyOwner { - if (v < 0.5 * 1e18) { + if (v < Decimal.unit() / 2) { revert ValueTooSmall(); } - if (v > 32 * 1e18) { + if (v > 32 * Decimal.unit()) { revert ValueTooLarge(); } baseRewardPerSecond = v; @@ -179,4 +181,8 @@ contract ConstantsManager is OwnableUpgradeable { } minAverageUptime = v; } + + function updateIssuedTokensRecipient(address v) external virtual onlyOwner { + issuedTokensRecipient = v; + } } diff --git a/contracts/sfc/NetworkInitializer.sol b/contracts/sfc/NetworkInitializer.sol index 15c7378..a738276 100644 --- a/contracts/sfc/NetworkInitializer.sol +++ b/contracts/sfc/NetworkInitializer.sol @@ -25,7 +25,7 @@ contract NetworkInitializer { NodeDriverAuth(_auth).initialize(_sfc, _driver, _owner); ConstantsManager consts = new ConstantsManager(address(this)); - consts.updateMinSelfStake(500000 * 1e18); + consts.updateMinSelfStake(500000 * Decimal.unit()); consts.updateMaxDelegatedRatio(16 * Decimal.unit()); consts.updateValidatorCommission((15 * Decimal.unit()) / 100); consts.updateBurntFeeShare((20 * Decimal.unit()) / 100); diff --git a/contracts/sfc/NodeDriver.sol b/contracts/sfc/NodeDriver.sol index bc1287d..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 { @@ -144,4 +149,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..834c47e 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); @@ -53,7 +58,6 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { _transferOwnership(executable); INodeDriverExecutable(executable).execute(); _transferOwnership(newOwner); - //require(driver.backend() == address(this), "ownership of driver is lost"); if (_getCodeHash(address(this)) != selfCodeHash) { revert SelfCodeHashMismatch(); } @@ -63,7 +67,7 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { } /// Execute a batch update of network configuration. - /// Run given contract with a permission of the NodeDriverAuth owner. + /// The executable will run with the privileges of the NodeDriverAuth owner. /// Does not allow changing NodeDriver and NodeDriverAuth code. function execute(address executable) external onlyOwner { _execute(executable, owner(), _getCodeHash(address(this)), _getCodeHash(address(driver))); @@ -83,13 +87,10 @@ 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); } - /// Upgrade code of given contract by coping it from other deployed contract. + /// Upgrade code of given contract by copying it from other deployed contract. /// Avoids setting code to an external address. function upgradeCode(address acc, address from) external onlyOwner { if (!isContract(acc) || !isContract(from)) { @@ -98,7 +99,7 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { driver.copyCode(acc, from); } - /// Upgrade code of given contract by coping it from other deployed contract. + /// Upgrade code of given contract by copying it from other deployed contract. /// Does not avoid setting code to an external address. (DANGEROUS!) function copyCode(address acc, address from) external onlyOwner { driver.copyCode(acc, from); @@ -171,19 +172,12 @@ contract NodeDriverAuth is OwnableUpgradeable, UUPSUpgradeable { } function isContract(address account) internal view returns (bool) { - uint256 size; - // solhint-disable-next-line no-inline-assembly - assembly { - size := extcodesize(account) - } - return size > 0; + return account.code.length > 0; } function _getCodeHash(address addr) internal view returns (bytes32) { - bytes32 codeHash; - assembly { - codeHash := extcodehash(addr) - } - return codeHash; + return addr.codehash; } + + uint256[50] private __gap; } diff --git a/contracts/sfc/SFC.sol b/contracts/sfc/SFC.sol index 49795fe..8577a74 100644 --- a/contracts/sfc/SFC.sol +++ b/contracts/sfc/SFC.sol @@ -7,6 +7,7 @@ import {Decimal} from "../common/Decimal.sol"; import {NodeDriverAuth} from "./NodeDriverAuth.sol"; import {ConstantsManager} from "./ConstantsManager.sol"; import {Version} from "../version/Version.sol"; +import {IStakeSubscriber} from "../interfaces/IStakeSubscriber.sol"; /** * @title Special Fee Contract for Sonic network @@ -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; @@ -81,17 +85,17 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { struct EpochSnapshot { // validator ID => validator weight in the epoch - mapping(uint256 => uint256) receivedStake; + mapping(uint256 validatorID => uint256) receivedStake; // validator ID => accumulated ( delegatorsReward * 1e18 / receivedStake ) - mapping(uint256 => uint256) accumulatedRewardPerToken; + mapping(uint256 validatorID => uint256) accumulatedRewardPerToken; // validator ID => accumulated online time - mapping(uint256 => uint256) accumulatedUptime; + mapping(uint256 validatorID => uint256) accumulatedUptime; // validator ID => average uptime as a percentage - mapping(uint256 => AverageUptime) averageUptime; + mapping(uint256 validatorID => AverageUptime) averageUptime; // validator ID => gas fees from txs originated by the validator - mapping(uint256 => uint256) accumulatedOriginatedTxsFee; - mapping(uint256 => uint256) offlineTime; - mapping(uint256 => uint256) offlineBlocks; + mapping(uint256 validatorID => uint256) accumulatedOriginatedTxsFee; + mapping(uint256 validatorID => uint256) offlineTime; + mapping(uint256 validatorID => uint256) offlineBlocks; uint256[] validatorIDs; uint256 endTime; uint256 endBlock; @@ -149,6 +153,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { // values error ZeroAmount(); error ZeroRewards(); + error ValueTooLarge(); // pubkeys error PubkeyUsedByOtherValidator(); @@ -157,6 +162,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { // redirections error AlreadyRedirected(); error SameRedirectionAuthorizer(); + error Redirected(); // validators error ValidatorNotExists(); @@ -189,6 +195,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( @@ -199,12 +209,20 @@ 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); + event BurntNativeTokens(uint256 amount); 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 (!_isNodeDriverAuth(msg.sender)) { @@ -213,6 +231,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( @@ -271,7 +294,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } /// Accept redirection proposal. - /// Redirection must by accepted by the validator key holder before it start to be applied. + /// Redirection must by accepted by the validator key holder before it starts to be applied. function redirect(address to) external { address from = msg.sender; if (to == address(0)) { @@ -414,9 +437,42 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } } - /// burnFTM allows SFC to burn an arbitrary amount of FTM tokens. - function burnFTM(uint256 amount) external onlyOwner { - _burnFTM(amount); + /// Resolve failed treasury transfers and send the unresolved fees to the treasury address. + function resolveTreasuryFees() external { + if (treasuryAddress == address(0)) { + revert TreasuryNotSet(); + } + if (unresolvedTreasuryFees == 0) { + revert NoUnresolvedTreasuryFees(); + } + + // 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(fees); + } + + /// Burn native tokens by sending them to the SFC contract. + function burnNativeTokens() external payable { + if (msg.value == 0) { + revert ZeroAmount(); + } + _burnNativeTokens(msg.value); + } + + /// 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.incBalance(c.issuedTokensRecipient(), amount); + totalSupply += amount; } /// Update treasury address. @@ -547,6 +603,11 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { return getEpochSnapshot[epoch].endBlock; } + /// Get epoch end time. + function epochEndTime(uint256 epoch) public view returns (uint256) { + return getEpochSnapshot[epoch].endTime; + } + /// Check whether the given validator is slashed - the stake (or its part) cannot /// be withdrawn because of misbehavior (double-sign) of the validator. function isSlashed(uint256 validatorID) public view returns (bool) { @@ -644,7 +705,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } /// Get slashing penalty for a stake. - function getSlashingPenalty( + function _getSlashingPenalty( uint256 amount, bool isCheater, uint256 refundRatio @@ -688,7 +749,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { uint256 amount = getWithdrawalRequest[delegator][toValidatorID][wrID].amount; bool isCheater = isSlashed(toValidatorID); - uint256 penalty = getSlashingPenalty(amount, isCheater, slashingRefundRatio[toValidatorID]); + uint256 penalty = _getSlashingPenalty(amount, isCheater, slashingRefundRatio[toValidatorID]); delete getWithdrawalRequest[delegator][toValidatorID][wrID]; if (amount <= penalty) { @@ -699,9 +760,9 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { if (!sent) { revert TransferFailed(); } - _burnFTM(penalty); + _burnNativeTokens(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. @@ -763,18 +824,22 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { return rewards; } - /// Burn FTM tokens. + /// Burn native tokens. /// The tokens are sent to the zero address. - function _burnFTM(uint256 amount) internal { + function _burnNativeTokens(uint256 amount) internal { if (amount != 0) { + if (amount > totalSupply) { + revert ValueTooLarge(); + } + totalSupply -= amount; payable(address(0)).transfer(amount); - emit BurntFTM(amount); + emit BurntNativeTokens(amount); } } - /// Get epoch end time. - function epochEndTime(uint256 epoch) internal view returns (uint256) { - return getEpochSnapshot[epoch].endTime; + /// Check if an address is redirected. + function _redirected(address addr) internal view returns (bool) { + return getRedirection[addr] != address(0); } /// Get address which should receive rewards and withdrawn stake for the given delegator. @@ -816,7 +881,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { EpochSnapshot storage prevSnapshot, uint256[] memory validatorIDs, uint256[] memory uptimes, - uint256[] memory originatedTxsFee + uint256[] memory accumulatedOriginatedTxsFee ) internal { SealEpochRewardsCtx memory ctx = SealEpochRewardsCtx( new uint256[](validatorIDs.length), @@ -828,16 +893,16 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { for (uint256 i = 0; i < validatorIDs.length; i++) { uint256 prevAccumulatedTxsFee = prevSnapshot.accumulatedOriginatedTxsFee[validatorIDs[i]]; - uint256 accumulatedTxsFee = 0; - if (originatedTxsFee[i] > prevAccumulatedTxsFee) { - accumulatedTxsFee = originatedTxsFee[i] - prevAccumulatedTxsFee; + uint256 originatedTxsFee = 0; + if (accumulatedOriginatedTxsFee[i] > prevAccumulatedTxsFee) { + originatedTxsFee = accumulatedOriginatedTxsFee[i] - prevAccumulatedTxsFee; } // txRewardWeight = {originatedTxsFee} * {uptime} // originatedTxsFee is roughly proportional to {uptime} * {stake}, so the whole formula is roughly // {stake} * {uptime} ^ 2 - ctx.txRewardWeights[i] = (accumulatedTxsFee * uptimes[i]) / epochDuration; + ctx.txRewardWeights[i] = (originatedTxsFee * uptimes[i]) / epochDuration; ctx.totalTxRewardWeight = ctx.totalTxRewardWeight + ctx.txRewardWeights[i]; - ctx.epochFee = ctx.epochFee + accumulatedTxsFee; + ctx.epochFee = ctx.epochFee + originatedTxsFee; } for (uint256 i = 0; i < validatorIDs.length; i++) { @@ -879,7 +944,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { prevSnapshot.accumulatedRewardPerToken[validatorID] + rewardPerToken; - snapshot.accumulatedOriginatedTxsFee[validatorID] = originatedTxsFee[i]; + snapshot.accumulatedOriginatedTxsFee[validatorID] = accumulatedOriginatedTxsFee[i]; snapshot.accumulatedUptime[validatorID] = prevSnapshot.accumulatedUptime[validatorID] + uptimes[i]; } @@ -899,6 +964,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; } } } @@ -952,6 +1020,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { if (cur.averageUptime > Decimal.unit()) { cur.averageUptime = uint64(Decimal.unit()); + cur.remainder = 0; // reset the remainder when capping the averageUptime } if (prev.epochs < c.averageUptimeEpochWindow()) { cur.epochs = prev.epochs + 1; @@ -1026,6 +1095,22 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { totalSupply = totalSupply + amount; } + /// Sync validator with node. + function _syncValidator(uint256 validatorID, bool syncPubkey) internal { + if (!_validatorExists(validatorID)) { + revert ValidatorNotExists(); + } + // emit special log for node + uint256 weight = getValidator[validatorID].receivedStake; + if (getValidator[validatorID].status != OK_STATUS) { + weight = 0; + } + node.updateValidatorWeight(validatorID, weight); + if (syncPubkey && weight != 0) { + node.updateValidatorPubkey(validatorID, getValidatorPubkey[validatorID]); + } + } + /// Notify stake subscriber about staking changes. /// Used to recount votes from delegators in the governance contract. function _notifyStakeSubscriber(address delegator, address validatorAuth, bool strict) internal { @@ -1033,7 +1118,7 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { // Don't allow announceStakeChange to use up all the gas // solhint-disable-next-line avoid-low-level-calls (bool success, ) = stakeSubscriberAddress.call{gas: 8000000}( - abi.encodeWithSignature("announceStakeChange(address,address)", delegator, validatorAuth) + abi.encodeCall(IStakeSubscriber.announceStakeChange, (delegator, validatorAuth)) ); // Don't revert if announceStakeChange failed unless strict mode enabled if (!success && strict) { @@ -1063,22 +1148,6 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { } } - /// Sync validator with node. - function _syncValidator(uint256 validatorID, bool syncPubkey) public { - if (!_validatorExists(validatorID)) { - revert ValidatorNotExists(); - } - // emit special log for node - uint256 weight = getValidator[validatorID].receivedStake; - if (getValidator[validatorID].status != OK_STATUS) { - weight = 0; - } - node.updateValidatorWeight(validatorID, weight); - if (syncPubkey && weight != 0) { - node.updateValidatorPubkey(validatorID, getValidatorPubkey[validatorID]); - } - } - /// Check if a validator exists. function _validatorExists(uint256 validatorID) internal view returns (bool) { return getValidator[validatorID].createdTime != 0; @@ -1098,4 +1167,6 @@ contract SFC is OwnableUpgradeable, UUPSUpgradeable, Version { function _now() internal view virtual returns (uint256) { return block.timestamp; } + + uint256[50] private __gap; } 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/contracts/test/UnitTestSFC.sol b/contracts/test/UnitTestSFC.sol index 5a0022a..fe017c6 100644 --- a/contracts/test/UnitTestSFC.sol +++ b/contracts/test/UnitTestSFC.sol @@ -46,6 +46,10 @@ contract UnitTestSFC is SFC { } return SFC._isNodeDriverAuth(addr); } + + function syncValidator(uint256 validatorID, bool syncPubkey) public { + _syncValidator(validatorID, syncPubkey); + } } contract UnitTestNetworkInitializer { @@ -62,7 +66,7 @@ contract UnitTestNetworkInitializer { NodeDriverAuth(_auth).initialize(_sfc, _driver, _owner); UnitTestConstantsManager consts = new UnitTestConstantsManager(address(this)); - consts.updateMinSelfStake(0.3175000 * 1e18); + consts.updateMinSelfStake((3175 * Decimal.unit()) / 10000); consts.updateMaxDelegatedRatio(16 * Decimal.unit()); consts.updateValidatorCommission((15 * Decimal.unit()) / 100); consts.updateBurntFeeShare((20 * Decimal.unit()) / 100); 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" } diff --git a/test/SFC.ts b/test/SFC.ts index c3351c3..a7fbe77 100644 --- a/test/SFC.ts +++ b/test/SFC.ts @@ -8,6 +8,7 @@ import { BlockchainNode, ValidatorMetrics } from './helpers/BlockchainNode'; describe('SFC', () => { const fixture = async () => { const [owner, user] = await ethers.getSigners(); + const totalSupply = ethers.parseEther('100'); const sfc = await upgrades.deployProxy(await ethers.getContractFactory('UnitTestSFC'), { kind: 'uups', initializer: false, @@ -24,7 +25,7 @@ describe('SFC', () => { const evmWriter: IEVMWriter = await ethers.deployContract('StubEvmWriter'); const initializer: UnitTestNetworkInitializer = await ethers.deployContract('UnitTestNetworkInitializer'); - await initializer.initializeAll(0, 0, sfc, nodeDriverAuth, nodeDriver, evmWriter, owner); + await initializer.initializeAll(0, totalSupply, sfc, nodeDriverAuth, nodeDriver, evmWriter, owner); const constants: UnitTestConstantsManager = await ethers.getContractAt( 'UnitTestConstantsManager', await sfc.constsAddress(), @@ -39,6 +40,7 @@ describe('SFC', () => { nodeDriver, nodeDriverAuth, constants, + totalSupply, }; }; @@ -55,6 +57,32 @@ describe('SFC', () => { ).to.revertedWithCustomError(this.sfc, 'TransfersNotAllowed'); }); + describe('Burn native tokens', () => { + it('Should revert when no amount sent', async function () { + await expect(this.sfc.connect(this.user).burnNativeTokens()).to.be.revertedWithCustomError( + this.sfc, + 'ZeroAmount', + ); + }); + + it('Should revert when amount greater than total supply', async function () { + await expect( + this.sfc.connect(this.user).burnNativeTokens({ value: this.totalSupply + 1n }), + ).to.be.revertedWithCustomError(this.sfc, 'ValueTooLarge'); + }); + + it('Should succeed and burn native tokens', async function () { + const amount = ethers.parseEther('1.5'); + const totalSupply = await this.sfc.totalSupply(); + const tx = await this.sfc.connect(this.user).burnNativeTokens({ value: amount }); + await expect(tx).to.emit(this.sfc, 'BurntNativeTokens').withArgs(amount); + expect(await this.sfc.totalSupply()).to.equal(totalSupply - amount); + await expect(tx).to.changeEtherBalance(this.sfc, 0); + await expect(tx).to.changeEtherBalance(this.user, -amount); + await expect(tx).to.changeEtherBalance(ethers.ZeroAddress, amount); + }); + }); + describe('Genesis validator', () => { beforeEach(async function () { const validator = ethers.Wallet.createRandom(); @@ -65,7 +93,7 @@ describe('SFC', () => { }); it('Should succeed and set genesis validator with bad status', async function () { - await this.sfc._syncValidator(1, false); + await this.sfc.syncValidator(1, false); }); it('Should revert when sealEpoch not called by node', async function () { @@ -131,6 +159,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 = @@ -644,10 +696,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 +713,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 +726,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 +802,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 +856,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 +873,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); @@ -946,7 +1056,7 @@ describe('SFC', () => { }); it('Should revert when syncing if validator does not exist', async function () { - await expect(this.sfc._syncValidator(33, false)).to.be.revertedWithCustomError(this.sfc, 'ValidatorNotExists'); + await expect(this.sfc.syncValidator(33, false)).to.be.revertedWithCustomError(this.sfc, 'ValidatorNotExists'); }); }); @@ -982,7 +1092,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 +1101,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 +1110,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 +1120,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 +1130,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());