diff --git a/rayls-contracts/src/consensus/DelegationPool.sol b/rayls-contracts/src/consensus/DelegationPool.sol index e140ed56..c772eacf 100644 --- a/rayls-contracts/src/consensus/DelegationPool.sol +++ b/rayls-contracts/src/consensus/DelegationPool.sol @@ -219,7 +219,9 @@ contract DelegationPool is rewardPerShareAccum: 0, pendingValidatorRewards: 0, acceptingDelegations: true, - slashPerShareAccum: 0 + slashPerShareAccum: 0, + openTierDelegated: 0, + openRewardPerShareAccum: 0 }); emit PoolRegistered(msg.sender, commissionBps); @@ -368,7 +370,9 @@ contract DelegationPool is address validatorAddress, uint256 amount ) external override nonReentrant { - _delegate(validatorAddress, amount); + DelegationPoolStorage storage $ = _getDelegationPoolStorage(); + bool isOpenTier = $.whitelistEnabled && !$.whitelistVerified[msg.sender]; + _delegate(validatorAddress, amount, isOpenTier); } /// @inheritdoc IDelegationPool @@ -390,16 +394,15 @@ contract DelegationPool is $.whitelistVerified[msg.sender] = true; emit WhitelistVerified(msg.sender); } - _delegate(validatorAddress, amount); + // Proof submission always routes to Track A (whitelisted tier) + _delegate(validatorAddress, amount, false); } - function _delegate(address validatorAddress, uint256 amount) internal { + function _delegate(address validatorAddress, uint256 amount, bool isOpenTier) internal { if (validatorAddress == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); DelegationPoolStorage storage $ = _getDelegationPoolStorage(); - if ($.whitelistEnabled && !$.whitelistVerified[msg.sender]) - revert NotWhitelisted(msg.sender); if (!$.poolRegistered[validatorAddress]) revert PoolNotRegistered(validatorAddress); @@ -414,9 +417,11 @@ contract DelegationPool is if (amount < $.config.minDelegation) revert InsufficientDelegation(amount, $.config.minDelegation); - DelegatorPosition storage pos = $.positions[validatorAddress][ - msg.sender - ]; + DelegatorPosition storage pos = $.positions[validatorAddress][msg.sender]; + + // Existing positions keep their locked tier so open-tier holders can top up + // even after the whitelist is disabled (when isOpenTier would otherwise be false). + if (pos.amount > 0) isOpenTier = pos.openTier; // settle pending rewards and slashes before changing position _settlePosition(pool, pos); @@ -426,11 +431,11 @@ contract DelegationPool is if (newDelegatorTotal > $.config.maxDelegation) revert ExceedsMaxDelegation(newDelegatorTotal, $.config.maxDelegation); - // check per-validator max - uint256 newPoolTotal = pool.totalDelegated + amount; - if (newPoolTotal > $.config.maxValidatorDelegation) + // check combined pool cap (Track A + Track B together) + uint256 combinedPoolTotal = pool.totalDelegated + pool.openTierDelegated; + if (combinedPoolTotal + amount > $.config.maxValidatorDelegation) revert ExceedsMaxValidatorDelegation( - newPoolTotal, + combinedPoolTotal + amount, $.config.maxValidatorDelegation ); @@ -438,17 +443,21 @@ contract DelegationPool is $.rls.safeTransferFrom(msg.sender, address(this), amount); pos.amount = newDelegatorTotal; - pos.rewardDebt = - (pos.amount * pool.rewardPerShareAccum) / - PRECISION; + pos.openTier = isOpenTier; + + uint256 accum = isOpenTier ? pool.openRewardPerShareAccum : pool.rewardPerShareAccum; + pos.rewardDebt = (pos.amount * accum) / PRECISION; // Ceiling division for slashDebt — counterpart to ceiling in _settlePosition - pos.slashDebt = - (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / - PRECISION; + pos.slashDebt = (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / PRECISION; // Record delegation epoch — same-epoch rewards are excluded to prevent // sandwich attacks on distributePoolRewards. pos.lastDelegateEpoch = uint64($.consensusRegistry.getCurrentEpoch()); - pool.totalDelegated = newPoolTotal; + + if (isOpenTier) { + pool.openTierDelegated += amount; + } else { + pool.totalDelegated += amount; + } emit Delegated(validatorAddress, msg.sender, amount); } @@ -488,9 +497,8 @@ contract DelegationPool is // update position pos.amount -= amount; - pos.rewardDebt = - (pos.amount * pool.rewardPerShareAccum) / - PRECISION; + uint256 accum = pos.openTier ? pool.openRewardPerShareAccum : pool.rewardPerShareAccum; + pos.rewardDebt = (pos.amount * accum) / PRECISION; // Ceiling division for slashDebt — counterpart to ceiling in _settlePosition pos.slashDebt = (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / @@ -500,8 +508,12 @@ contract DelegationPool is uint32 currentEpoch = $.consensusRegistry.getCurrentEpoch(); pos.undelegateEpoch = uint64(currentEpoch) + $.config.unbondingEpochs; - // reduce pool total - pool.totalDelegated -= amount; + // reduce the correct track's total + if (pos.openTier) { + pool.openTierDelegated -= amount; + } else { + pool.totalDelegated -= amount; + } emit UndelegationRequested( validatorAddress, @@ -598,7 +610,8 @@ contract DelegationPool is // ========================================================================= /// @inheritdoc IDelegationPool - /// @dev RewardDistributor must transfer RLS tokens to this contract before calling + /// @dev Caller must transfer RLS tokens to this contract before calling + /// @dev Splits amount proportionally between Track A and Track B by stake function distributePoolRewards( address validatorAddress, uint256 amount @@ -609,27 +622,75 @@ contract DelegationPool is if (!$.poolRegistered[validatorAddress]) return; ValidatorPool storage pool = $.validatorPools[validatorAddress]; + uint256 totalAll = pool.totalDelegated + pool.openTierDelegated; - if (pool.totalDelegated == 0) { - // no delegators: all rewards go to validator as commission + if (totalAll == 0) { pool.pendingValidatorRewards += amount; emit PoolRewardsDistributed(validatorAddress, amount); return; } - // split into commission and delegator rewards - uint256 commission = (amount * pool.commissionBps) / - MAX_COMMISSION_BPS; - uint256 delegatorRewards = amount - commission; + // proportional split by stake + uint256 trackAAmount = (amount * pool.totalDelegated) / totalAll; + uint256 trackBAmount = amount - trackAAmount; + _distributePoolRewardsInternal(validatorAddress, pool, trackAAmount, trackBAmount); + } - pool.pendingValidatorRewards += commission; + /// @inheritdoc IDelegationPool + /// @dev RewardDistributor must transfer (trackAAmount + trackBAmount) RLS before calling + function distributePoolRewards( + address validatorAddress, + uint256 trackAAmount, + uint256 trackBAmount + ) external override onlyRewardSources { + if (trackAAmount == 0 && trackBAmount == 0) return; - // update reward accumulator - pool.rewardPerShareAccum += - (delegatorRewards * PRECISION) / - pool.totalDelegated; + DelegationPoolStorage storage $ = _getDelegationPoolStorage(); + if (!$.poolRegistered[validatorAddress]) return; - emit PoolRewardsDistributed(validatorAddress, amount); + ValidatorPool storage pool = $.validatorPools[validatorAddress]; + _distributePoolRewardsInternal(validatorAddress, pool, trackAAmount, trackBAmount); + } + + function _distributePoolRewardsInternal( + address validatorAddress, + ValidatorPool storage pool, + uint256 trackAAmount, + uint256 trackBAmount + ) internal { + uint256 totalAll = pool.totalDelegated + pool.openTierDelegated; + + if (totalAll == 0) { + pool.pendingValidatorRewards += trackAAmount + trackBAmount; + emit PoolRewardsDistributed(validatorAddress, trackAAmount + trackBAmount); + return; + } + + // Track A: commission + accumulator update + if (trackAAmount > 0) { + if (pool.totalDelegated == 0) { + pool.pendingValidatorRewards += trackAAmount; + } else { + uint256 commissionA = (trackAAmount * pool.commissionBps) / MAX_COMMISSION_BPS; + pool.pendingValidatorRewards += commissionA; + pool.rewardPerShareAccum += + ((trackAAmount - commissionA) * PRECISION) / pool.totalDelegated; + } + } + + // Track B: commission + accumulator update + if (trackBAmount > 0) { + if (pool.openTierDelegated == 0) { + pool.pendingValidatorRewards += trackBAmount; + } else { + uint256 commissionB = (trackBAmount * pool.commissionBps) / MAX_COMMISSION_BPS; + pool.pendingValidatorRewards += commissionB; + pool.openRewardPerShareAccum += + ((trackBAmount - commissionB) * PRECISION) / pool.openTierDelegated; + } + } + + emit PoolRewardsDistributed(validatorAddress, trackAAmount + trackBAmount); } /// @inheritdoc IDelegationPool @@ -644,26 +705,30 @@ contract DelegationPool is if (!$.poolRegistered[validatorAddress]) return 0; ValidatorPool storage pool = $.validatorPools[validatorAddress]; + uint256 totalAll = pool.totalDelegated + pool.openTierDelegated; - if (pool.totalDelegated == 0) { + if (totalAll == 0) { emit PoolSlashed(validatorAddress, 0); return 0; } - // cap slash at totalDelegated - effectiveSlash = amount > pool.totalDelegated - ? pool.totalDelegated - : amount; + // cap slash at combined delegated total + effectiveSlash = amount > totalAll ? totalAll : amount; // Compute per-share slash increment (rounds down due to integer division). // Derive actualSlash from the rounded value so the transfer matches what // the accumulator records. Dust stays in the contract as a solvency buffer, // preventing balance < aggregate claims over many slashes. - uint256 slashPerShare = (effectiveSlash * PRECISION) / pool.totalDelegated; - uint256 actualSlash = (slashPerShare * pool.totalDelegated) / PRECISION; + uint256 slashPerShare = (effectiveSlash * PRECISION) / totalAll; + uint256 actualSlash = (slashPerShare * totalAll) / PRECISION; pool.slashPerShareAccum += slashPerShare; - pool.totalDelegated -= actualSlash; + + // Reduce each track proportionally so their totals stay accurate for reward distribution. + uint256 slashA = (actualSlash * pool.totalDelegated) / totalAll; + uint256 slashB = actualSlash - slashA; + pool.totalDelegated -= slashA; + pool.openTierDelegated -= slashB; // transfer only what the accumulator accounts for $.rls.safeTransfer(address($.consensusRegistry), actualSlash); @@ -682,7 +747,15 @@ contract DelegationPool is function getTotalDelegatedStake( address validatorAddress ) external view override returns (uint256) { - return _getDelegationPoolStorage().validatorPools[validatorAddress].totalDelegated; + ValidatorPool storage pool = _getDelegationPoolStorage().validatorPools[validatorAddress]; + return pool.totalDelegated + pool.openTierDelegated; + } + + /// @inheritdoc IDelegationPool + function getTotalOpenTierDelegatedStake( + address validatorAddress + ) external view override returns (uint256) { + return _getDelegationPoolStorage().validatorPools[validatorAddress].openTierDelegated; } /// @inheritdoc IDelegationPool @@ -763,81 +836,76 @@ contract DelegationPool is if (config_.commissionDelayEpochs == 0) revert InvalidConfig(); } - /// @dev Calculates effective position after applying pending slashes and rewards. - /// Mirrors the arithmetic path of `_settlePosition` exactly for consistency. - function _getEffectivePosition( - address validatorAddress, - address delegator - ) internal view returns (uint256 effectiveAmount, uint256 pendingRewards) { - DelegationPoolStorage storage $ = _getDelegationPoolStorage(); - ValidatorPool storage pool = $.validatorPools[validatorAddress]; - DelegatorPosition storage pos = $.positions[validatorAddress][delegator]; - - if (pos.amount == 0) return (0, pos.pendingRewards); - - // 1. simulate slash settlement — mirror ceiling division in _settlePosition + /// @dev Pure settlement arithmetic shared by _settlePosition (mutating) and _getEffectivePosition (view). + /// Slash-first ordering prevents insolvency: rewards are computed on the post-slash amount, + /// which is consistent with how rewardPerShareAccum was updated on a reduced totalDelegated. + /// Returns (effectiveAmount, rewardDelta, newRewardDebt, newSlashDebt). + /// On full slash all four return values are zero. + function _settleArithmetic( + ValidatorPool storage pool, + DelegatorPosition storage pos, + uint64 currentEpoch + ) internal view returns ( + uint256 effectiveAmount, + uint256 rewardDelta, + uint256 newRewardDebt, + uint256 newSlashDebt + ) { + uint256 accum = pos.openTier ? pool.openRewardPerShareAccum : pool.rewardPerShareAccum; + + // 1. slash — ceiling division so per-delegator sum >= pool's actualSlash (solvency invariant) uint256 accumulatedSlash = (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / PRECISION; uint256 slashAmount = accumulatedSlash > pos.slashDebt ? accumulatedSlash - pos.slashDebt : 0; - if (slashAmount >= pos.amount) { - return (0, pos.pendingRewards); + uint256 scaledRewardDebt = pos.rewardDebt; + if (slashAmount > 0) { + if (slashAmount >= pos.amount) return (0, 0, 0, 0); + effectiveAmount = pos.amount - slashAmount; + // round UP to prevent overcrediting rewards after slash + scaledRewardDebt = (pos.rewardDebt * effectiveAmount + pos.amount - 1) / pos.amount; + } else { + effectiveAmount = pos.amount; } - effectiveAmount = pos.amount - slashAmount; - - // 2. simulate reward settlement — mirrors _settlePosition exactly - // Round rewardDebt UP after slash (same as _settlePosition ceiling division) - pendingRewards = pos.pendingRewards; - if (pos.lastDelegateEpoch != uint64($.consensusRegistry.getCurrentEpoch())) { - uint256 scaledRewardDebt = (pos.rewardDebt * effectiveAmount + pos.amount - 1) / pos.amount; - uint256 accumulated = (effectiveAmount * pool.rewardPerShareAccum) / PRECISION; + // 2. rewards on post-slash amount — skip same-epoch delegations (sandwich protection) + if (pos.lastDelegateEpoch != currentEpoch) { + uint256 accumulated = (effectiveAmount * accum) / PRECISION; if (accumulated > scaledRewardDebt) { - pendingRewards += accumulated - scaledRewardDebt; + rewardDelta = accumulated - scaledRewardDebt; } } + + // 3. updated debts + newRewardDebt = (effectiveAmount * accum) / PRECISION; + newSlashDebt = (effectiveAmount * pool.slashPerShareAccum + PRECISION - 1) / PRECISION; + } + + function _getEffectivePosition( + address validatorAddress, + address delegator + ) internal view returns (uint256 effectiveAmount, uint256 pendingRewards) { + DelegationPoolStorage storage $ = _getDelegationPoolStorage(); + ValidatorPool storage pool = $.validatorPools[validatorAddress]; + DelegatorPosition storage pos = $.positions[validatorAddress][delegator]; + if (pos.amount == 0) return (0, pos.pendingRewards); + uint64 currentEpoch = uint64($.consensusRegistry.getCurrentEpoch()); + uint256 rewardDelta; + (effectiveAmount, rewardDelta,,) = _settleArithmetic(pool, pos, currentEpoch); + pendingRewards = pos.pendingRewards + rewardDelta; } - /// @dev Settles pending slashes and rewards for a delegator position. /// @dev Must be called before any position mutation. - /// @dev Order: apply slash -> scale rewardDebt -> settle rewards (post-slash) -> recalculate debts. - /// Slash-first ordering prevents insolvency that would occur if rewards were calculated - /// on pre-slash amounts when rewardPerShareAccum was computed on a reduced totalDelegated. function _settlePosition( ValidatorPool storage pool, DelegatorPosition storage pos ) internal { if (pos.amount == 0) return; - - // 1. apply slash — round UP so per-delegator slash sum >= pool's actualSlash, - // keeping the pool solvent across slash cycles (counterpart to DP-001 floor in applyPoolSlash). - uint256 accumulatedSlash = (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / PRECISION; - uint256 slashAmount = accumulatedSlash > pos.slashDebt ? accumulatedSlash - pos.slashDebt : 0; - if (slashAmount > 0) { - uint256 preSlashAmount = pos.amount; - if (slashAmount >= pos.amount) { - pos.amount = 0; - pos.rewardDebt = 0; - } else { - pos.amount -= slashAmount; - // Round rewardDebt UP to prevent overcrediting rewards after slash - pos.rewardDebt = (pos.rewardDebt * pos.amount + preSlashAmount - 1) / preSlashAmount; - } - } - - // 2. settle rewards using post-slash amount - // Skip rewards for positions delegated in the current epoch to prevent - // sandwich attacks on distributePoolRewards. uint64 currentEpoch = uint64(_getDelegationPoolStorage().consensusRegistry.getCurrentEpoch()); - if (pos.amount > 0 && pos.lastDelegateEpoch != currentEpoch) { - uint256 accumulated = (pos.amount * pool.rewardPerShareAccum) / PRECISION; - if (accumulated > pos.rewardDebt) { - pos.pendingRewards += accumulated - pos.rewardDebt; - } - } - - // 3. recalculate debts based on new amount - pos.rewardDebt = (pos.amount * pool.rewardPerShareAccum) / PRECISION; - // Ceiling division for slashDebt to mirror accumulatedSlash above - pos.slashDebt = (pos.amount * pool.slashPerShareAccum + PRECISION - 1) / PRECISION; + (uint256 effectiveAmount, uint256 rewardDelta, uint256 newRewardDebt, uint256 newSlashDebt) = + _settleArithmetic(pool, pos, currentEpoch); + pos.amount = effectiveAmount; + if (rewardDelta > 0) pos.pendingRewards += rewardDelta; + pos.rewardDebt = newRewardDebt; + pos.slashDebt = newSlashDebt; } } diff --git a/rayls-contracts/src/fees/RewardDistributor.sol b/rayls-contracts/src/fees/RewardDistributor.sol index 7629b431..410af047 100644 --- a/rayls-contracts/src/fees/RewardDistributor.sol +++ b/rayls-contracts/src/fees/RewardDistributor.sol @@ -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)) @@ -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; @@ -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 @@ -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; diff --git a/rayls-contracts/src/interfaces/IDelegationPool.sol b/rayls-contracts/src/interfaces/IDelegationPool.sol index fbe61a24..339cb42d 100644 --- a/rayls-contracts/src/interfaces/IDelegationPool.sol +++ b/rayls-contracts/src/interfaces/IDelegationPool.sol @@ -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 @@ -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 @@ -215,9 +219,10 @@ 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( @@ -225,6 +230,18 @@ interface IDelegationPool { 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 @@ -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, diff --git a/rayls-contracts/src/interfaces/IRewardDistributor.sol b/rayls-contracts/src/interfaces/IRewardDistributor.sol index fa73091e..4f8a2460 100644 --- a/rayls-contracts/src/interfaces/IRewardDistributor.sol +++ b/rayls-contracts/src/interfaces/IRewardDistributor.sol @@ -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 @@ -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; } diff --git a/rayls-contracts/test/consensus/DelegationPoolTest.t.sol b/rayls-contracts/test/consensus/DelegationPoolTest.t.sol index 1f02c307..c9bfe276 100644 --- a/rayls-contracts/test/consensus/DelegationPoolTest.t.sol +++ b/rayls-contracts/test/consensus/DelegationPoolTest.t.sol @@ -1876,13 +1876,18 @@ contract DelegationPoolTest is Test { // --- gate enabled: revert paths --- - function testRevert_whitelist_twoArgNotVerified() public { + function test_whitelist_twoArgNotVerified_delegatesAsOpenTier() public { _wlRegisterPool(); _wlEnableGate(); + // Non-verified callers no longer revert — they are routed to open-tier (Track B) vm.prank(delegator1); - vm.expectRevert(abi.encodeWithSelector(IDelegationPool.NotWhitelisted.selector, delegator1)); pool.delegate(validator1, 5e18); + + IDelegationPool.DelegatorPosition memory pos = pool.getDelegatorPosition(validator1, delegator1); + assertEq(pos.amount, 5e18); + assertTrue(pos.openTier); + assertFalse(pool.isWhitelistVerified(delegator1)); } function testRevert_whitelist_emptyProof() public { @@ -1939,32 +1944,26 @@ contract DelegationPoolTest is Test { // --- toggling --- - function test_whitelist_toggleGateOff_lettingOutsiderDelegate_thenBackOn() public { + function test_whitelist_toggleGateOff_tierAssignmentFollowsGate() public { _wlRegisterPool(); _wlEnableGate(); - // outsider blocked - vm.prank(outsider); - vm.expectRevert(abi.encodeWithSelector(IDelegationPool.NotWhitelisted.selector, outsider)); - pool.delegate(validator1, 5e18); - - // admin disables gate - vm.prank(owner); - pool.disableWhitelist(); - - // outsider now succeeds, and no cache entry gets written + // Gate on + not verified → open-tier (Track B) vm.prank(outsider); pool.delegate(validator1, 5e18); assertFalse(pool.isWhitelistVerified(outsider)); + assertTrue(pool.getDelegatorPosition(validator1, outsider).openTier); assertEq(pool.getDelegatorPosition(validator1, outsider).amount, 5e18); - // re-enable by re-setting the root — outsider is blocked again (no cache) + // Admin disables gate — all new delegations become Track A (isOpenTier=false). + // Existing open-tier positions keep their locked tier, so outsider can still top up. vm.prank(owner); - pool.setWhitelistRoot(wlRoot); + pool.disableWhitelist(); vm.prank(outsider); - vm.expectRevert(abi.encodeWithSelector(IDelegationPool.NotWhitelisted.selector, outsider)); pool.delegate(validator1, 5e18); + assertEq(pool.getDelegatorPosition(validator1, outsider).amount, 10e18); + assertTrue(pool.getDelegatorPosition(validator1, outsider).openTier); } function test_whitelist_cachedEntrySurvivesGateToggle() public { diff --git a/rayls-contracts/test/fees/RewardDistributorExtendedTest.t.sol b/rayls-contracts/test/fees/RewardDistributorExtendedTest.t.sol index ffe9529e..2281592c 100644 --- a/rayls-contracts/test/fees/RewardDistributorExtendedTest.t.sol +++ b/rayls-contracts/test/fees/RewardDistributorExtendedTest.t.sol @@ -137,9 +137,17 @@ contract MockDelegationPoolExt { return delegatedStakes[validator]; } + function getTotalOpenTierDelegatedStake(address) external pure returns (uint256) { + return 0; + } + function distributePoolRewards(address validator, uint256 amount) external { distributedRewards[validator] += amount; } + + function distributePoolRewards(address validator, uint256 trackAAmount, uint256 trackBAmount) external { + distributedRewards[validator] += trackAAmount + trackBAmount; + } } contract RewardDistributorExtendedTest is Test { diff --git a/rayls-contracts/test/fees/RewardDistributorTest.t.sol b/rayls-contracts/test/fees/RewardDistributorTest.t.sol index 7c10e88f..0074b991 100644 --- a/rayls-contracts/test/fees/RewardDistributorTest.t.sol +++ b/rayls-contracts/test/fees/RewardDistributorTest.t.sol @@ -138,9 +138,17 @@ contract MockDelegationPool { return delegatedStakes[validator]; } + function getTotalOpenTierDelegatedStake(address) external pure returns (uint256) { + return 0; + } + function distributePoolRewards(address validator, uint256 amount) external { distributedRewards[validator] += amount; } + + function distributePoolRewards(address validator, uint256 trackAAmount, uint256 trackBAmount) external { + distributedRewards[validator] += trackAAmount + trackBAmount; + } } contract RewardDistributorTest is Test {