Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions contracts/BlockReward.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
}
319 changes: 319 additions & 0 deletions contracts/StakingRewards.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
// 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 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;

/// @notice Contract owner.
address public owner;

/// @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;

/// @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();
error ContractPaused();

/// @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);

/// @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();
_;
}

/// @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();
_;
}

/// @dev Restricts access when the contract is paused.
modifier whenNotPaused() {
if (paused) revert ContractPaused();
_;
}

/// @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;
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 {
if (newOperator == address(0)) revert ZeroAddress();

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 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`.
/// 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]) return;

if (paused) return;

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
whenNotPaused
{
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 whenNotPaused nonReentrant {
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 whenNotPaused nonReentrant {
_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 = Math.mulDiv(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 = Math.mulDiv(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);
}
}
1 change: 1 addition & 0 deletions contracts/interfaces/IConsensus.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions contracts/interfaces/IStakingRewards.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.24;

interface IStakingRewards {
function recordBlockReward(
uint256 cycle,
address[] validators,
uint256[] weights
) external;
}
Loading