From 7ee0c55f593251a6c4ba4c590c0460a776298649 Mon Sep 17 00:00:00 2001 From: mul53 Date: Tue, 9 Jun 2026 00:03:34 +0200 Subject: [PATCH 1/3] add staking v1 --- contracts/StakingRewards.sol | 284 +++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 contracts/StakingRewards.sol diff --git a/contracts/StakingRewards.sol b/contracts/StakingRewards.sol new file mode 100644 index 0000000..834debe --- /dev/null +++ b/contracts/StakingRewards.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title StakingRewards +/// @notice Records validator/user reward weights per cycle and allows users to claim their proportional ETH rewards. +/// @dev The block reward contract records weights. The owner/operator funds and finalizes each cycle reward. +contract StakingRewards { + /// @notice Address of the trusted block reward contract. + /// @dev Only this address can call `recordBlockReward`. + address public immutable blockReward; + + /// @notice Contract owner. + address public owner; + + /// @notice Optional operator allowed to finalize cycle rewards. + address public operator; + + /// @notice User reward weight per cycle. + /// @dev cycle => user => weight. + mapping(uint256 => mapping(address => uint256)) public userCycleWeights; + + /// @notice Total reward weight recorded for each cycle. + /// @dev Used as the denominator when calculating proportional rewards. + mapping(uint256 => uint256) public totalCycleWeights; + + /// @notice ETH reward amount assigned to each cycle. + /// @dev cycle => reward amount in wei. + mapping(uint256 => uint256) public cycleRewards; + + /// @notice Whether a cycle reward has been finalized. + /// @dev Users can only claim rewards for finalized cycles. + mapping(uint256 => bool) public cycleFinalized; + + error NotBlockReward(); + error NotOperatorOrOwner(); + error NotOwner(); + error ZeroAddress(); + error ZeroReward(); + error CycleAlreadyFinalized(); + error CycleNotFinalized(); + error NoRewardsToClaim(); + error NoTotalWeight(); + error RewardTransferFailed(); + + /// @notice Emitted when reward weights are recorded for a cycle. + /// @param cycle The cycle being recorded. + /// @param totalWeightAdded The total valid weight added during this call. + /// @param receiversLength Number of receivers passed in. + /// @param rewardWeightsLength Number of reward weights passed in. + event BlockRewardRecorded( + uint256 indexed cycle, + uint256 totalWeightAdded, + uint256 receiversLength, + uint256 rewardWeightsLength + ); + + /// @notice Emitted when a cycle reward is funded and finalized. + /// @param cycle The finalized cycle. + /// @param rewardAmount The ETH reward amount assigned to the cycle. + event CycleRewardFinalized( + uint256 indexed cycle, + uint256 rewardAmount + ); + + /// @notice Emitted when a user claims a reward. + /// @param user The user claiming the reward. + /// @param cycle The cycle being claimed. + /// @param rewardAmount The ETH amount claimed. + /// @param userWeight The user's recorded weight for the cycle. + event RewardClaimed( + address indexed user, + uint256 indexed cycle, + uint256 rewardAmount, + uint256 userWeight + ); + + /// @notice Emitted when the operator is updated. + /// @param oldOperator Previous operator address. + /// @param newOperator New operator address. + event OperatorUpdated(address indexed oldOperator, address indexed newOperator); + + /// @notice Emitted when ownership is transferred. + /// @param oldOwner Previous owner address. + /// @param newOwner New owner address. + event OwnershipTransferred(address indexed oldOwner, address indexed newOwner); + + /// @dev Restricts access to the block reward contract. + modifier onlyBlockReward() { + if (msg.sender != blockReward) revert NotBlockReward(); + _; + } + + /// @dev Restricts access to the owner or operator. + modifier onlyOperatorOrOwner() { + if (msg.sender != owner && msg.sender != operator) revert NotOperatorOrOwner(); + _; + } + + /// @dev Restricts access to the owner. + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + /// @notice Deploys the staking rewards contract. + /// @param _blockReward Address of the trusted block reward contract. + /// @param _operator Address of the initial operator. + constructor(address _blockReward, address _operator) { + if (_blockReward == address(0)) revert ZeroAddress(); + + blockReward = _blockReward; + owner = msg.sender; + operator = _operator; + } + + /// @notice Updates the operator address. + /// @dev Can only be called by the owner. + /// @param newOperator New operator address. + function setOperator(address newOperator) external onlyOwner { + address oldOperator = operator; + operator = newOperator; + + emit OperatorUpdated(oldOperator, newOperator); + } + + /// @notice Transfers ownership to a new owner. + /// @dev Can only be called by the current owner. + /// @param newOwner New owner address. + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + + address oldOwner = owner; + owner = newOwner; + + emit OwnershipTransferred(oldOwner, newOwner); + } + + /// @notice Records user reward weights for a cycle. + /// @dev + /// Does not revert if `receivers.length != rewardWeights.length`. + /// Instead, it processes only the smaller length. + /// + /// Invalid entries are skipped: + /// - receiver is address(0) + /// - weight is 0 + /// + /// This protects the system from full-call reverts caused by bad individual entries. + /// + /// @param cycle The cycle being recorded. + /// @param receivers List of reward receivers. + /// @param rewardWeights List of corresponding reward weights. + function recordBlockReward( + uint256 cycle, + address[] calldata receivers, + uint256[] calldata rewardWeights + ) external onlyBlockReward { + if (cycleFinalized[cycle]) revert CycleAlreadyFinalized(); + + uint256 receiversLength = receivers.length; + uint256 weightLength = rewardWeights.length; + + uint256 length = receiversLength < weightLength + ? receiversLength + : weightLength; + + uint256 totalWeightAdded; + + for (uint256 i; i < length; ) { + address receiver = receivers[i]; + uint256 weight = rewardWeights[i]; + + if (receiver != address(0) && weight != 0) { + userCycleWeights[cycle][receiver] += weight; + totalWeightAdded += weight; + } + + unchecked { + ++i; + } + } + + if (totalWeightAdded != 0) { + totalCycleWeights[cycle] += totalWeightAdded; + } + + emit BlockRewardRecorded( + cycle, + totalWeightAdded, + receiversLength, + weightLength + ); + } + + /// @notice Funds and finalizes a cycle reward. + /// @dev + /// Once finalized, no more weights can be added for that cycle. + /// Users can only claim after the cycle has been finalized. + /// + /// This prevents users from claiming before the final reward amount is known. + /// + /// @param cycle The cycle to finalize. + function finalizeCycleReward(uint256 cycle) + external + payable + onlyOperatorOrOwner + { + if (msg.value == 0) revert ZeroReward(); + if (cycleFinalized[cycle]) revert CycleAlreadyFinalized(); + if (totalCycleWeights[cycle] == 0) revert NoTotalWeight(); + + cycleRewards[cycle] = msg.value; + cycleFinalized[cycle] = true; + + emit CycleRewardFinalized(cycle, msg.value); + } + + /// @notice Claims rewards for multiple finalized cycles. + /// @dev Reverts if any cycle in the list is not claimable by the caller. + /// @param cycles List of cycle IDs to claim. + function claimRewards(uint256[] calldata cycles) external { + uint256 length = cycles.length; + + for (uint256 i; i < length; ) { + _claimReward(cycles[i]); + + unchecked { + ++i; + } + } + } + + /// @notice Claims reward for one finalized cycle. + /// @param cycle The cycle to claim. + function claimReward(uint256 cycle) external { + _claimReward(cycle); + } + + /// @notice Returns a user's pending reward for a cycle. + /// @dev Returns 0 if the cycle is not finalized, user has no weight, or total weight is 0. + /// @param user The user address. + /// @param cycle The cycle ID. + /// @return rewardAmount The pending ETH reward amount in wei. + function pendingReward(address user, uint256 cycle) + external + view + returns (uint256 rewardAmount) + { + if (!cycleFinalized[cycle]) return 0; + + uint256 userWeight = userCycleWeights[cycle][user]; + if (userWeight == 0) return 0; + + uint256 totalWeight = totalCycleWeights[cycle]; + if (totalWeight == 0) return 0; + + rewardAmount = (cycleRewards[cycle] * userWeight) / totalWeight; + } + + /// @notice Internal reward claim logic. + /// @dev + /// Clears the user's cycle weight before transferring ETH to prevent re-claiming. + /// Uses `call` instead of `transfer` to avoid the 2300 gas stipend limitation. + /// + /// @param cycle The cycle to claim. + function _claimReward(uint256 cycle) internal { + if (!cycleFinalized[cycle]) revert CycleNotFinalized(); + + uint256 userWeight = userCycleWeights[cycle][msg.sender]; + if (userWeight == 0) revert NoRewardsToClaim(); + + uint256 totalWeight = totalCycleWeights[cycle]; + if (totalWeight == 0) revert NoTotalWeight(); + + uint256 rewardAmount = (cycleRewards[cycle] * userWeight) / totalWeight; + if (rewardAmount == 0) revert NoRewardsToClaim(); + + userCycleWeights[cycle][msg.sender] = 0; + + (bool success, ) = payable(msg.sender).call{value: rewardAmount}(""); + if (!success) revert RewardTransferFailed(); + + emit RewardClaimed(msg.sender, cycle, rewardAmount, userWeight); + } +} From 5a8d1f589f54d683c76f1eaa9b0c4bcc47f01abe Mon Sep 17 00:00:00 2001 From: mul53 Date: Tue, 16 Jun 2026 18:42:18 +0200 Subject: [PATCH 2/3] add audit fixes --- contracts/StakingRewards.sol | 47 +++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/contracts/StakingRewards.sol b/contracts/StakingRewards.sol index 834debe..a0bea92 100644 --- a/contracts/StakingRewards.sol +++ b/contracts/StakingRewards.sol @@ -1,10 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + + /// @title StakingRewards /// @notice Records validator/user reward weights per cycle and allows users to claim their proportional ETH rewards. /// @dev The block reward contract records weights. The owner/operator funds and finalizes each cycle reward. -contract StakingRewards { +contract StakingRewards is ReentrancyGuard { + using Math for uint256; + /// @notice Address of the trusted block reward contract. /// @dev Only this address can call `recordBlockReward`. address public immutable blockReward; @@ -15,6 +21,10 @@ contract StakingRewards { /// @notice Optional operator allowed to finalize cycle rewards. address public operator; + /// @notice Whether reward recording is paused. + /// @dev When paused, `recordBlockReward` calls will be ignored. + bool public paused; + /// @notice User reward weight per cycle. /// @dev cycle => user => weight. mapping(uint256 => mapping(address => uint256)) public userCycleWeights; @@ -41,6 +51,7 @@ contract StakingRewards { error NoRewardsToClaim(); error NoTotalWeight(); error RewardTransferFailed(); + error Paused(); /// @notice Emitted when reward weights are recorded for a cycle. /// @param cycle The cycle being recorded. @@ -84,6 +95,10 @@ contract StakingRewards { /// @param newOwner New owner address. event OwnershipTransferred(address indexed oldOwner, address indexed newOwner); + /// @notice Emitted when the paused state is updated. + /// @param paused The new paused state. + event Paused(bool paused); + /// @dev Restricts access to the block reward contract. modifier onlyBlockReward() { if (msg.sender != blockReward) revert NotBlockReward(); @@ -102,11 +117,18 @@ contract StakingRewards { _; } + /// @dev Restricts access when the contract is paused. + modifier whenNotPaused() { + if (paused) revert Paused(); + _; + } + /// @notice Deploys the staking rewards contract. /// @param _blockReward Address of the trusted block reward contract. /// @param _operator Address of the initial operator. constructor(address _blockReward, address _operator) { if (_blockReward == address(0)) revert ZeroAddress(); + if (_operator == address(0)) revert ZeroAddress(); blockReward = _blockReward; owner = msg.sender; @@ -117,6 +139,8 @@ contract StakingRewards { /// @dev Can only be called by the owner. /// @param newOperator New operator address. function setOperator(address newOperator) external onlyOwner { + if (newOperator == address(0)) revert ZeroAddress(); + address oldOperator = operator; operator = newOperator; @@ -135,6 +159,14 @@ contract StakingRewards { emit OwnershipTransferred(oldOwner, newOwner); } + /// @notice Pauses reward recording. + /// @dev When paused, `recordBlockReward` calls will be ignored. + function setPaused(bool newPauseValue) external onlyOwner { + paused = newPauseValue; + + emit Paused(newPauseValue); + } + /// @notice Records user reward weights for a cycle. /// @dev /// Does not revert if `receivers.length != rewardWeights.length`. @@ -154,7 +186,9 @@ contract StakingRewards { address[] calldata receivers, uint256[] calldata rewardWeights ) external onlyBlockReward { - if (cycleFinalized[cycle]) revert CycleAlreadyFinalized(); + if (cycleFinalized[cycle]) return; + + if (paused) return; uint256 receiversLength = receivers.length; uint256 weightLength = rewardWeights.length; @@ -203,6 +237,7 @@ contract StakingRewards { external payable onlyOperatorOrOwner + whenNotPaused { if (msg.value == 0) revert ZeroReward(); if (cycleFinalized[cycle]) revert CycleAlreadyFinalized(); @@ -217,7 +252,7 @@ contract StakingRewards { /// @notice Claims rewards for multiple finalized cycles. /// @dev Reverts if any cycle in the list is not claimable by the caller. /// @param cycles List of cycle IDs to claim. - function claimRewards(uint256[] calldata cycles) external { + function claimRewards(uint256[] calldata cycles) external whenNotPaused nonReentrant { uint256 length = cycles.length; for (uint256 i; i < length; ) { @@ -231,7 +266,7 @@ contract StakingRewards { /// @notice Claims reward for one finalized cycle. /// @param cycle The cycle to claim. - function claimReward(uint256 cycle) external { + function claimReward(uint256 cycle) external whenNotPaused nonReentrant { _claimReward(cycle); } @@ -253,7 +288,7 @@ contract StakingRewards { uint256 totalWeight = totalCycleWeights[cycle]; if (totalWeight == 0) return 0; - rewardAmount = (cycleRewards[cycle] * userWeight) / totalWeight; + rewardAmount = Math.mulDiv(cycleRewards[cycle], userWeight, totalWeight); } /// @notice Internal reward claim logic. @@ -271,7 +306,7 @@ contract StakingRewards { uint256 totalWeight = totalCycleWeights[cycle]; if (totalWeight == 0) revert NoTotalWeight(); - uint256 rewardAmount = (cycleRewards[cycle] * userWeight) / totalWeight; + uint256 rewardAmount = Math.mulDiv(cycleRewards[cycle], userWeight, totalWeight); if (rewardAmount == 0) revert NoRewardsToClaim(); userCycleWeights[cycle][msg.sender] = 0; From c9c1fae8a3b090f3c444e3b3d20ff5bb09d09717 Mon Sep 17 00:00:00 2001 From: mul53 Date: Thu, 18 Jun 2026 07:48:53 +0200 Subject: [PATCH 3/3] add fixes and tests --- contracts/BlockReward.sol | 21 ++ contracts/StakingRewards.sol | 4 +- contracts/interfaces/IConsensus.sol | 1 + contracts/interfaces/IStakingRewards.sol | 10 + hardhat.config.js | 25 +- package.json | 1 + test/stakingRewards.test.js | 291 +++++++++++++++++++++++ yarn.lock | 5 + 8 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 contracts/interfaces/IStakingRewards.sol create mode 100644 test/stakingRewards.test.js diff --git a/contracts/BlockReward.sol b/contracts/BlockReward.sol index 6b40d4e..7a5e6a8 100644 --- a/contracts/BlockReward.sol +++ b/contracts/BlockReward.sol @@ -2,6 +2,7 @@ pragma solidity ^0.4.24; import "./abstracts/BlockRewardBase.sol"; import "./interfaces/IConsensus.sol"; +import "./interfaces/IStakingRewards.sol"; import "./eternal-storage/EternalStorage.sol"; import "./ProxyStorage.sol"; import "openzeppelin-solidity/contracts/math/SafeMath.sol"; @@ -100,6 +101,15 @@ contract BlockReward is EternalStorage, BlockRewardBase { rewards[0] = rewards[0].sub(rewards[i]); } + address stakingAddress = getStakingAddress(); + if (stakingAddress != address(0)) { + IStakingRewards(stakingAddress).recordBlockReward( + IConsensus(ProxyStorage(getProxyStorage()).getConsensus()).getCurrentCycleStartBlock(), + receivers, + rewards + ); + } + _setRewardedOnCycle(getRewardedOnCycle().add(blockRewardAmount)); _setTotalSupply(getTotalSupply().add(blockRewardAmount)); @@ -134,6 +144,7 @@ contract BlockReward is EternalStorage, BlockRewardBase { bytes32 internal constant REWARDED_THIS_CYCLE = keccak256(abi.encodePacked("rewardedOnCycle")); bytes32 internal constant BLOCK_REWARD_AMOUNT = keccak256(abi.encodePacked("blockRewardAmount")); bytes32 internal constant SHOULD_EMIT_REWARDED_ON_CYCLE = keccak256(abi.encodePacked("shouldEmitRewardedOnCycle")); + bytes32 internal constant STAKING_ADDRESS = keccak256(abi.encodePacked("stakingAddress")); function _setSystemAddress(address _newAddress) private { addressStorage[SYSTEM_ADDRESS] = _newAddress; @@ -208,4 +219,14 @@ contract BlockReward is EternalStorage, BlockRewardBase { function _setShouldEmitRewardedOnCycle(bool _status) internal { boolStorage[SHOULD_EMIT_REWARDED_ON_CYCLE] = _status; } + + function setStakingAddress(address _stakingAddress) external onlyOwner { + require(_stakingAddress != address(0)); + + addressStorage[STAKING_ADDRESS] = _stakingAddress; + } + + function getStakingAddress() public view returns(address) { + return addressStorage[STAKING_ADDRESS]; + } } diff --git a/contracts/StakingRewards.sol b/contracts/StakingRewards.sol index a0bea92..b2d4278 100644 --- a/contracts/StakingRewards.sol +++ b/contracts/StakingRewards.sol @@ -51,7 +51,7 @@ contract StakingRewards is ReentrancyGuard { error NoRewardsToClaim(); error NoTotalWeight(); error RewardTransferFailed(); - error Paused(); + error ContractPaused(); /// @notice Emitted when reward weights are recorded for a cycle. /// @param cycle The cycle being recorded. @@ -119,7 +119,7 @@ contract StakingRewards is ReentrancyGuard { /// @dev Restricts access when the contract is paused. modifier whenNotPaused() { - if (paused) revert Paused(); + if (paused) revert ContractPaused(); _; } diff --git a/contracts/interfaces/IConsensus.sol b/contracts/interfaces/IConsensus.sol index 69a019f..6ee5a06 100644 --- a/contracts/interfaces/IConsensus.sol +++ b/contracts/interfaces/IConsensus.sol @@ -4,6 +4,7 @@ interface IConsensus { function currentValidatorsLength() external view returns(uint256); function currentValidatorsAtPosition(uint256 _p) external view returns(address); function getCycleDurationBlocks() external view returns(uint256); + function getCurrentCycleStartBlock() external view returns(uint256); function getCurrentCycleEndBlock() external view returns(uint256); function cycle(address _validator) external; function isValidator(address _address) external view returns(bool); diff --git a/contracts/interfaces/IStakingRewards.sol b/contracts/interfaces/IStakingRewards.sol new file mode 100644 index 0000000..f3426ea --- /dev/null +++ b/contracts/interfaces/IStakingRewards.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.4.24; + +interface IStakingRewards { + function recordBlockReward( + uint256 cycle, + address[] validators, + uint256[] weights + ) external; +} diff --git a/hardhat.config.js b/hardhat.config.js index 8b0f0d9..ca69fc3 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -45,13 +45,26 @@ module.exports = { }, }, solidity: { - version: "0.4.24", - settings: { - optimizer: { - enabled: true, - runs: 200, + compilers: [ + { + version: "0.4.24", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, }, - }, + { + version: "0.8.20", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + ], }, paths: { sources: "./contracts", diff --git a/package.json b/package.json index 9ddd975..553bc3c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "author": "Lior Rabin", "license": "MIT", "dependencies": { + "@openzeppelin/contracts": "^5.6.1", "dotenv": "^8.0.0", "ethers": "5.6.2", "keythereum": "^2.0.0", diff --git a/test/stakingRewards.test.js b/test/stakingRewards.test.js new file mode 100644 index 0000000..680778b --- /dev/null +++ b/test/stakingRewards.test.js @@ -0,0 +1,291 @@ +const StakingRewards = artifacts.require("StakingRewards.sol"); +const { ZERO_ADDRESS } = require("./helpers"); +const { ZERO, ONE, TWO, THREE } = require("./helpers"); +const { toBN, toWei } = web3.utils; + +contract("StakingRewards", async (accounts) => { + let stakingRewards; + + const owner = accounts[0]; + const blockReward = accounts[1]; + const operator = accounts[2]; + const nonOwner = accounts[3]; + const user = accounts[4]; + const secondUser = accounts[5]; + const newOperator = accounts[6]; + const newOwner = accounts[7]; + const cycle = ONE; + const rewardAmount = toWei(toBN(1), "ether"); + + beforeEach(async () => { + stakingRewards = await StakingRewards.new(blockReward, operator, { + from: owner, + }); + }); + + describe("constructor", async () => { + it("sets default values", async () => { + blockReward.should.equal(await stakingRewards.blockReward()); + owner.should.equal(await stakingRewards.owner()); + operator.should.equal(await stakingRewards.operator()); + false.should.equal(await stakingRewards.paused()); + }); + + it("rejects zero addresses", async () => { + await StakingRewards.new(ZERO_ADDRESS, operator, { from: owner }).should.be + .rejected; + await StakingRewards.new(blockReward, ZERO_ADDRESS, { from: owner }).should + .be.rejected; + }); + }); + + describe("admin", async () => { + it("allows only the owner to set operator", async () => { + await stakingRewards.setOperator(newOperator, { from: nonOwner }).should.be + .rejected; + + const { logs } = await stakingRewards.setOperator(newOperator, { + from: owner, + }).should.be.fulfilled; + + newOperator.should.equal(await stakingRewards.operator()); + logs[0].event.should.equal("OperatorUpdated"); + logs[0].args.oldOperator.should.equal(operator); + logs[0].args.newOperator.should.equal(newOperator); + }); + + it("allows only the owner to transfer ownership", async () => { + await stakingRewards.transferOwnership(newOwner, { from: nonOwner }).should + .be.rejected; + + const { logs } = await stakingRewards.transferOwnership(newOwner, { + from: owner, + }).should.be.fulfilled; + + newOwner.should.equal(await stakingRewards.owner()); + logs[0].event.should.equal("OwnershipTransferred"); + logs[0].args.oldOwner.should.equal(owner); + logs[0].args.newOwner.should.equal(newOwner); + }); + + it("allows only the owner to pause and unpause", async () => { + await stakingRewards.setPaused(true, { from: nonOwner }).should.be.rejected; + + let result = await stakingRewards.setPaused(true, { from: owner }).should.be + .fulfilled; + true.should.equal(await stakingRewards.paused()); + result.logs[0].event.should.equal("Paused"); + result.logs[0].args.paused.should.equal(true); + + result = await stakingRewards.setPaused(false, { from: owner }).should.be + .fulfilled; + false.should.equal(await stakingRewards.paused()); + result.logs[0].args.paused.should.equal(false); + }); + }); + + describe("recordBlockReward", async () => { + it("can only be called by the block reward contract", async () => { + await stakingRewards.recordBlockReward(cycle, [user], [TWO], { + from: nonOwner, + }).should.be.rejected; + + await stakingRewards.recordBlockReward(cycle, [user], [TWO], { + from: blockReward, + }).should.be.fulfilled; + }); + + it("records valid weights and skips invalid entries", async () => { + const { logs } = await stakingRewards.recordBlockReward( + cycle, + [user, ZERO_ADDRESS, secondUser, user], + [TWO, THREE, ZERO, ONE], + { from: blockReward } + ).should.be.fulfilled; + + THREE.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, user) + ); + ZERO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, secondUser) + ); + THREE.should.be.bignumber.equal( + await stakingRewards.totalCycleWeights(cycle) + ); + + logs[0].event.should.equal("BlockRewardRecorded"); + logs[0].args.cycle.should.be.bignumber.equal(cycle); + logs[0].args.totalWeightAdded.should.be.bignumber.equal(THREE); + logs[0].args.receiversLength.should.be.bignumber.equal(toBN(4)); + logs[0].args.rewardWeightsLength.should.be.bignumber.equal(toBN(4)); + }); + + it("processes only the shorter input length", async () => { + await stakingRewards.recordBlockReward(cycle, [user, secondUser], [TWO], { + from: blockReward, + }).should.be.fulfilled; + + TWO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, user) + ); + ZERO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, secondUser) + ); + TWO.should.be.bignumber.equal( + await stakingRewards.totalCycleWeights(cycle) + ); + }); + + it("does not record when paused or finalized", async () => { + await stakingRewards.setPaused(true, { from: owner }); + await stakingRewards.recordBlockReward(cycle, [user], [TWO], { + from: blockReward, + }).should.be.fulfilled; + ZERO.should.be.bignumber.equal( + await stakingRewards.totalCycleWeights(cycle) + ); + + await stakingRewards.setPaused(false, { from: owner }); + await stakingRewards.recordBlockReward(cycle, [user], [TWO], { + from: blockReward, + }); + await stakingRewards.finalizeCycleReward(cycle, { + from: operator, + value: rewardAmount, + }); + await stakingRewards.recordBlockReward(cycle, [user], [THREE], { + from: blockReward, + }).should.be.fulfilled; + + TWO.should.be.bignumber.equal( + await stakingRewards.totalCycleWeights(cycle) + ); + }); + }); + + describe("finalizeCycleReward", async () => { + beforeEach(async () => { + await stakingRewards.recordBlockReward(cycle, [user], [TWO], { + from: blockReward, + }); + }); + + it("can be called by the owner or operator", async () => { + await stakingRewards.finalizeCycleReward(cycle, { + from: nonOwner, + value: rewardAmount, + }).should.be.rejected; + + const { logs } = await stakingRewards.finalizeCycleReward(cycle, { + from: operator, + value: rewardAmount, + }).should.be.fulfilled; + + rewardAmount.should.be.bignumber.equal( + await stakingRewards.cycleRewards(cycle) + ); + true.should.equal(await stakingRewards.cycleFinalized(cycle)); + logs[0].event.should.equal("CycleRewardFinalized"); + logs[0].args.cycle.should.be.bignumber.equal(cycle); + logs[0].args.rewardAmount.should.be.bignumber.equal(rewardAmount); + }); + + it("rejects invalid finalization", async () => { + await stakingRewards.finalizeCycleReward(cycle, { + from: owner, + value: ZERO, + }).should.be.rejected; + + await stakingRewards.finalizeCycleReward(TWO, { + from: owner, + value: rewardAmount, + }).should.be.rejected; + + await stakingRewards.setPaused(true, { from: owner }); + await stakingRewards.finalizeCycleReward(cycle, { + from: owner, + value: rewardAmount, + }).should.be.rejected; + }); + }); + + describe("claim", async () => { + beforeEach(async () => { + await stakingRewards.recordBlockReward( + cycle, + [user, secondUser], + [ONE, THREE], + { + from: blockReward, + } + ); + await stakingRewards.finalizeCycleReward(cycle, { + from: operator, + value: rewardAmount, + }); + }); + + it("returns pending rewards", async () => { + toBN(toWei("0.25", "ether")).should.be.bignumber.equal( + await stakingRewards.pendingReward(user, cycle) + ); + toBN(toWei("0.75", "ether")).should.be.bignumber.equal( + await stakingRewards.pendingReward(secondUser, cycle) + ); + ZERO.should.be.bignumber.equal( + await stakingRewards.pendingReward(nonOwner, cycle) + ); + }); + + it("allows users to claim a finalized reward once", async () => { + const expectedReward = toBN(toWei("0.25", "ether")); + const balanceBefore = toBN(await web3.eth.getBalance(user)); + const receipt = await stakingRewards.claimReward(cycle, { from: user }) + .should.be.fulfilled; + const balanceAfter = toBN(await web3.eth.getBalance(user)); + const tx = await web3.eth.getTransaction(receipt.tx); + const gasCost = toBN(tx.gasPrice).mul(toBN(receipt.receipt.gasUsed)); + + balanceAfter.should.be.bignumber.equal( + balanceBefore.add(expectedReward).sub(gasCost) + ); + ZERO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, user) + ); + receipt.logs[0].event.should.equal("RewardClaimed"); + receipt.logs[0].args.user.should.equal(user); + receipt.logs[0].args.rewardAmount.should.be.bignumber.equal(expectedReward); + + await stakingRewards.claimReward(cycle, { from: user }).should.be.rejected; + }); + + it("allows claiming multiple cycles", async () => { + await stakingRewards.recordBlockReward(TWO, [user], [ONE], { + from: blockReward, + }); + await stakingRewards.finalizeCycleReward(TWO, { + from: owner, + value: rewardAmount, + }); + + await stakingRewards.claimRewards([cycle, TWO], { from: user }).should.be + .fulfilled; + + ZERO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(cycle, user) + ); + ZERO.should.be.bignumber.equal( + await stakingRewards.userCycleWeights(TWO, user) + ); + }); + + it("rejects claims for paused, unfinalized, or empty rewards", async () => { + await stakingRewards.claimReward(TWO, { from: user }).should.be.rejected; + await stakingRewards.claimReward(cycle, { from: nonOwner }).should.be + .rejected; + + await stakingRewards.setPaused(true, { from: owner }); + await stakingRewards.claimReward(cycle, { from: user }).should.be.rejected; + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3e2e426..daa43b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1187,6 +1187,11 @@ ethers "^4.0.0-beta.1" source-map-support "^0.5.19" +"@openzeppelin/contracts@^5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-5.6.1.tgz#90c1cd427b3c1007ada4f42378ce84cc2a2145a5" + integrity sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w== + "@scure/base@~1.1.0": version "1.1.3" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.3.tgz#8584115565228290a6c6c4961973e0903bb3df2f"