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
280 changes: 174 additions & 106 deletions rayls-contracts/src/consensus/DelegationPool.sol

Large diffs are not rendered by default.

199 changes: 134 additions & 65 deletions rayls-contracts/src/fees/RewardDistributor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ contract RewardDistributor is
uint256 targetApyBps;
/// @notice Sum of all pendingValidatorRewards — protects unclaimed rewards from recoverTokens
uint256 totalUnclaimedRewards;
/// @notice Target APY in basis points for open-tier (Track B) stakers (e.g., 3000 = 30%)
uint256 openTierTargetApyBps;
}

// keccak256(abi.encode(uint256(keccak256("rewarddistributor.storage.v1")) - 1)) & ~bytes32(uint256(0xff))
Expand Down Expand Up @@ -149,116 +151,131 @@ contract RewardDistributor is
/// @inheritdoc IRewardDistributor
/// @dev Distributes all pending rewards in a single call.
/// Uses performance weights (stake × headerCount) if available, falls back to pure stake.
/// Fetches each validator's own-stake and delegated-stake exactly once (used for both
/// APY top-up calculation and the validator/pool reward split).
/// Stakes are fetched on-demand per validator in both the weight-building pass and the
/// distribution pass to avoid stack-too-deep with the dual-track arrays.
function distributeRewards() external override onlySystemCall nonReentrant {
RewardDistributorStorage storage $ = _getRewardDistributorStorage();

uint256 totalRewards = $.totalPending;

// Distribution keys and pre-fetched stake data (one pass over validators)
address[] memory validators;
uint256[] memory weights;
uint256[] memory ownStakes;
uint256[] memory delegatedStakes;
uint256 totalWeight;
uint256 totalStaked;

IConsensusRegistry.PerformanceWeights memory perf = $.consensusRegistry.getEpochPerformanceWeights();

if (perf.totalWeight > 0 && perf.validators.length > 0) {
uint256 n = perf.validators.length;
validators = perf.validators;
weights = perf.weights;
totalWeight = perf.totalWeight;
ownStakes = new uint256[](n);
delegatedStakes = new uint256[](n);

// Single pass: fetch stake data for APY calc + later split
for (uint256 i; i < n; ++i) {
(, uint256 ownStake, ) = IStakeManager(address($.consensusRegistry)).getBalanceBreakdown(validators[i]);
ownStakes[i] = ownStake;
uint256 delegated;
if (address($.delegationPool) != address(0)) {
delegated = $.delegationPool.getTotalDelegatedStake(validators[i]);
uint256 totalPriorityStake;
uint256 totalOpenTierStake;
uint256[] memory ownStakes;
uint256[] memory trackAStakes;
uint256[] memory trackBStakes;

// Use scoped block so `perf` is freed from the stack before the fallback block
{
IConsensusRegistry.PerformanceWeights memory perf =
$.consensusRegistry.getEpochPerformanceWeights();

if (perf.totalWeight > 0 && perf.validators.length > 0) {
validators = perf.validators;
weights = perf.weights;
totalWeight = perf.totalWeight;
ownStakes = new uint256[](validators.length);
trackAStakes = new uint256[](validators.length);
trackBStakes = new uint256[](validators.length);
for (uint256 i; i < validators.length; ++i) {
(uint256 os, uint256 ta, uint256 tb) = _fetchValidatorStakes(validators[i]);
ownStakes[i] = os;
trackAStakes[i] = ta;
trackBStakes[i] = tb;
totalPriorityStake += os + ta;
totalOpenTierStake += tb;
}
delegatedStakes[i] = delegated;
totalStaked += ownStake + delegated;
}
}

totalRewards = _pullAccumulatorTopUp(totalRewards, totalStaked);
} else {
if (totalWeight == 0) {
IConsensusRegistry.ValidatorInfo[] memory activeValidators = $.consensusRegistry.getValidators(
IConsensusRegistry.ValidatorStatus.Active
);
if (activeValidators.length == 0) revert NoActiveValidators();

uint256 n = activeValidators.length;
validators = new address[](n);
weights = new uint256[](n);
ownStakes = new uint256[](n);
delegatedStakes = new uint256[](n);

for (uint256 i; i < n; ++i) {
address validatorAddr = activeValidators[i].validatorAddress;
(, uint256 ownStake, ) = IStakeManager(address($.consensusRegistry)).getBalanceBreakdown(validatorAddr);

uint256 delegated;
if (address($.delegationPool) != address(0)) {
delegated = $.delegationPool.getTotalDelegatedStake(validatorAddr);
}

validators[i] = validatorAddr;
ownStakes[i] = ownStake;
delegatedStakes[i] = delegated;
weights[i] = ownStake + delegated;
validators = new address[](activeValidators.length);
weights = new uint256[](activeValidators.length);
ownStakes = new uint256[](activeValidators.length);
trackAStakes = new uint256[](activeValidators.length);
trackBStakes = new uint256[](activeValidators.length);
for (uint256 i; i < activeValidators.length; ++i) {
(uint256 os, uint256 ta, uint256 tb) = _fetchValidatorStakes(activeValidators[i].validatorAddress);
validators[i] = activeValidators[i].validatorAddress;
ownStakes[i] = os;
trackAStakes[i] = ta;
trackBStakes[i] = tb;
weights[i] = os + ta + tb;
totalWeight += weights[i];
totalPriorityStake += os + ta;
totalOpenTierStake += tb;
}

totalRewards = _pullAccumulatorTopUp(totalRewards, totalWeight);
}

if (totalWeight == 0) revert NoActiveValidators();

totalRewards = _pullAccumulatorTopUp(totalRewards, totalPriorityStake, totalOpenTierStake);

if (totalRewards == 0) {
emit RewardsDistributed(0, 0);
return;
}

// Distribute to each validator proportionally using pre-fetched stakes
uint256 distributed;
for (uint256 i; i < validators.length; ++i) {
uint256 len = validators.length;
for (uint256 i; i < len; ++i) {
uint256 validatorReward = (totalRewards * weights[i]) / totalWeight;
if (validatorReward == 0) continue;

distributed += _distributeToValidator(validators[i], validatorReward, ownStakes[i], delegatedStakes[i]);
distributed += _distributeToValidator(
validators[i], validatorReward, ownStakes[i], trackAStakes[i], trackBStakes[i]
);
}

// Subtract full totalRewards so rounding dust is freed from totalPending
$.totalPending -= totalRewards;
emit RewardsDistributed(distributed, validators.length);
emit RewardsDistributed(distributed, len);
}

/// @dev Distributes a reward to a validator, splitting between own stake and delegation pool.
/// @dev Fetches ownStake, Track A delegated, and Track B delegated for a validator in one call.
function _fetchValidatorStakes(address validator)
internal
view
returns (uint256 ownStake, uint256 trackA, uint256 trackB)
{
RewardDistributorStorage storage $ = _getRewardDistributorStorage();
(, ownStake, ) = IStakeManager(address($.consensusRegistry)).getBalanceBreakdown(validator);
if (address($.delegationPool) != address(0)) {
trackB = $.delegationPool.getTotalOpenTierDelegatedStake(validator);
trackA = $.delegationPool.getTotalDelegatedStake(validator) - trackB;
}
}

/// @dev Distributes a reward to a validator, splitting between own stake and per-track pool shares.
/// Uses pre-fetched stake values to avoid redundant external calls.
function _distributeToValidator(
address validatorAddr,
uint256 validatorReward,
uint256 ownStake,
uint256 delegatedStake
uint256 trackADelegated,
uint256 trackBDelegated
) internal returns (uint256) {
if (validatorReward == 0) return 0;

RewardDistributorStorage storage $ = _getRewardDistributorStorage();
uint256 totalValidatorStake = ownStake + delegatedStake;
uint256 totalDelegated = trackADelegated + trackBDelegated;
uint256 totalValidatorStake = ownStake + totalDelegated;

if (delegatedStake > 0 && totalValidatorStake > 0 && address($.delegationPool) != address(0)) {
if (totalDelegated > 0 && totalValidatorStake > 0 && address($.delegationPool) != address(0)) {
uint256 validatorShare = (validatorReward * ownStake) / totalValidatorStake;
uint256 poolShare = validatorReward - validatorShare;

if (poolShare > 0) {
(uint256 trackAShare, uint256 trackBShare) =
_splitPoolShare(poolShare, trackADelegated, trackBDelegated);

$.rls.safeTransfer(address($.delegationPool), poolShare);
$.delegationPool.distributePoolRewards(validatorAddr, poolShare);
$.delegationPool.distributePoolRewards(validatorAddr, trackAShare, trackBShare);
}

$.pendingValidatorRewards[validatorAddr] += validatorShare;
Expand All @@ -273,6 +290,32 @@ contract RewardDistributor is
return validatorReward;
}

/// @dev Splits a pool reward between Track A and Track B using APY-weighted stakes.
/// Each track's share is proportional to (stake * targetApy), ensuring that
/// the combined organic + subsidy reward approximates the configured APY ratio.
/// Falls back to pure stake proportion when both APYs are zero.
function _splitPoolShare(
uint256 poolShare,
uint256 trackADelegated,
uint256 trackBDelegated
) internal view returns (uint256 trackAShare, uint256 trackBShare) {
if (trackBDelegated == 0) return (poolShare, 0);
if (trackADelegated == 0) return (0, poolShare);

RewardDistributorStorage storage $ = _getRewardDistributorStorage();
uint256 weightA = trackADelegated * $.targetApyBps;
uint256 weightB = trackBDelegated * $.openTierTargetApyBps;
uint256 totalWeight = weightA + weightB;

if (totalWeight == 0) {
// Both APYs zero: fall back to stake proportion
trackAShare = (poolShare * trackADelegated) / (trackADelegated + trackBDelegated);
} else {
trackAShare = (poolShare * weightA) / totalWeight;
}
trackBShare = poolShare - trackAShare;
}

// ========== CLAIMS ==========

/// @notice Claim pending rewards for a validator
Expand Down Expand Up @@ -381,17 +424,43 @@ contract RewardDistributor is
emit TargetApyBpsUpdated(oldApyBps, newApyBps);
}

/// @inheritdoc IRewardDistributor
function openTierTargetApyBps() external view override returns (uint256) {
return _getRewardDistributorStorage().openTierTargetApyBps;
}

/// @inheritdoc IRewardDistributor
function setOpenTierTargetApyBps(uint256 newApyBps) external override onlyRole(DEFAULT_ADMIN_ROLE) {
if (newApyBps > MAX_APY_BPS) revert InvalidApyBps();
RewardDistributorStorage storage $ = _getRewardDistributorStorage();
uint256 oldApyBps = $.openTierTargetApyBps;
$.openTierTargetApyBps = newApyBps;
emit OpenTierApyBpsUpdated(oldApyBps, newApyBps);
}

/// @dev Pull RLS from the accumulator to cover APY shortfall.
/// Computes separate targets for priority stake (ownStake + Track A, using targetApyBps)
/// and open-tier stake (Track B, using openTierTargetApyBps), then pulls combined shortfall.
/// Never reverts — failed pull is silently skipped.
function _pullAccumulatorTopUp(uint256 currentRewards, uint256 totalStaked) internal returns (uint256) {
function _pullAccumulatorTopUp(
uint256 currentRewards,
uint256 totalPriorityStake,
uint256 totalOpenTierStake
) internal returns (uint256) {
RewardDistributorStorage storage $ = _getRewardDistributorStorage();

if ($.accumulator == address(0) || $.targetApyBps == 0 || totalStaked == 0) {
return currentRewards;
}
if ($.accumulator == address(0)) return currentRewards;

uint256 totalStaked = totalPriorityStake + totalOpenTierStake;
if (totalStaked == 0) return currentRewards;

uint256 epochSecs = $.consensusRegistry.getCurrentEpochInfo().epochDuration;
uint256 targetReward = (totalStaked * $.targetApyBps * epochSecs) / (365 days * 10_000);

// Combined target: priority stake at targetApyBps, open-tier at openTierTargetApyBps
uint256 targetReward = (
totalPriorityStake * $.targetApyBps +
totalOpenTierStake * $.openTierTargetApyBps
) * epochSecs / (365 days * 10_000);

if (targetReward <= currentRewards) {
return currentRewards;
Expand Down
30 changes: 26 additions & 4 deletions rayls-contracts/src/interfaces/IDelegationPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ interface IDelegationPool {
uint256 pendingValidatorRewards;
bool acceptingDelegations;
uint256 slashPerShareAccum;
// Track B: open-tier (non-whitelisted) delegators — independent reward stream
uint256 openTierDelegated;
uint256 openRewardPerShareAccum;
}

/// @notice Per-delegator position within a validator's pool
Expand All @@ -30,6 +33,7 @@ interface IDelegationPool {
uint256 undelegateAmount;
uint256 slashDebt;
uint64 lastDelegateEpoch; // epoch of most recent delegation — same-epoch rewards excluded (DP-NEW-002)
bool openTier; // immutable after entry; true = Track B (open-tier), false = Track A (whitelisted)
}

/// @notice Pending commission increase awaiting activation
Expand Down Expand Up @@ -215,16 +219,29 @@ interface IDelegationPool {

// === RewardDistributor integration ===

/// @notice Distribute ERC-20 RLS rewards to a validator's delegation pool
/// @dev Only callable by RewardDistributor
/// @dev RewardDistributor must transfer RLS tokens before calling this
/// @notice Distribute ERC-20 RLS rewards to a validator's delegation pool (legacy single-amount)
/// @dev Only callable by ConsensusRegistry or RewardDistributor
/// @dev Caller must transfer RLS tokens before calling this
/// @dev Splits the amount proportionally between Track A and Track B by stake
/// @param validatorAddress The validator receiving rewards
/// @param amount The amount of RLS tokens to distribute
function distributePoolRewards(
address validatorAddress,
uint256 amount
) external;

/// @notice Distribute ERC-20 RLS rewards to a validator's pool with per-track amounts
/// @dev Only callable by RewardDistributor
/// @dev RewardDistributor must transfer (trackAAmount + trackBAmount) RLS tokens before calling
/// @param validatorAddress The validator receiving rewards
/// @param trackAAmount RLS to distribute to whitelisted (Track A) delegators
/// @param trackBAmount RLS to distribute to open-tier (Track B) delegators
function distributePoolRewards(
address validatorAddress,
uint256 trackAAmount,
uint256 trackBAmount
) external;

/// @notice Apply a slash to a validator's delegation pool
/// @dev Only callable by ConsensusRegistry
/// @param validatorAddress The validator being slashed
Expand All @@ -242,12 +259,17 @@ interface IDelegationPool {
/// @notice Check if a pool is registered for a validator
function poolRegistered(address validatorAddress) external view returns (bool);

/// @notice Get the total delegated stake for a validator
/// @notice Get the total delegated stake for a validator (Track A + Track B combined)
/// @dev Intended for Rust consensus to read for future weighted voting power
function getTotalDelegatedStake(
address validatorAddress
) external view returns (uint256);

/// @notice Get the open-tier (Track B) delegated stake for a validator
function getTotalOpenTierDelegatedStake(
address validatorAddress
) external view returns (uint256);

/// @notice Get a delegator's pending (unclaimed) rewards
function getPendingRewards(
address validatorAddress,
Expand Down
12 changes: 10 additions & 2 deletions rayls-contracts/src/interfaces/IRewardDistributor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface IRewardDistributor {
event AccumulatorTopUpFailed(uint256 pullAmount);
event AccumulatorUpdated(address indexed oldAccumulator, address indexed newAccumulator);
event TargetApyBpsUpdated(uint256 oldApyBps, uint256 newApyBps);
event OpenTierApyBpsUpdated(uint256 oldApyBps, uint256 newApyBps);

/// @notice Receive ERC-20 RLS rewards from FeeAggregator
/// @dev Called by FeeAggregator after swapping USDr to RLS
Expand Down Expand Up @@ -97,10 +98,17 @@ interface IRewardDistributor {
/// @param newAccumulator The new accumulator address
function setAccumulator(address newAccumulator) external;

/// @notice Get the target APY in basis points
/// @notice Get the target APY in basis points for whitelisted (Track A) stakers
function targetApyBps() external view returns (uint256);

/// @notice Set the target APY in basis points (e.g., 5000 = 50%)
/// @notice Set the target APY in basis points for whitelisted (Track A) stakers
/// @param newApyBps The new target APY
function setTargetApyBps(uint256 newApyBps) external;

/// @notice Get the target APY in basis points for open-tier (Track B) stakers
function openTierTargetApyBps() external view returns (uint256);

/// @notice Set the target APY in basis points for open-tier (Track B) stakers
/// @param newApyBps The new target APY
function setOpenTierTargetApyBps(uint256 newApyBps) external;
}
Loading
Loading