solana_stake_program/
points.rs

1//! Information about points calculation based on stake state.
2//! Used by `solana-runtime`.
3
4use {
5    solana_clock::Epoch,
6    solana_instruction::error::InstructionError,
7    solana_pubkey::Pubkey,
8    solana_stake_interface::state::{Delegation, Stake, StakeStateV2},
9    solana_sysvar::stake_history::StakeHistory,
10    solana_vote_interface::state::VoteState,
11    std::cmp::Ordering,
12};
13
14/// captures a rewards round as lamports to be awarded
15///  and the total points over which those lamports
16///  are to be distributed
17//  basically read as rewards/points, but in integers instead of as an f64
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct PointValue {
20    pub rewards: u64, // lamports to split
21    pub points: u128, // over these points
22}
23
24#[derive(Debug, PartialEq, Eq)]
25pub(crate) struct CalculatedStakePoints {
26    pub(crate) points: u128,
27    pub(crate) new_credits_observed: u64,
28    pub(crate) force_credits_update_with_skipped_reward: bool,
29}
30
31#[derive(Debug)]
32pub enum InflationPointCalculationEvent {
33    CalculatedPoints(u64, u128, u128, u128),
34    SplitRewards(u64, u64, u64, PointValue),
35    EffectiveStakeAtRewardedEpoch(u64),
36    RentExemptReserve(u64),
37    Delegation(Delegation, Pubkey),
38    Commission(u8),
39    CreditsObserved(u64, Option<u64>),
40    Skipped(SkippedReason),
41}
42
43pub(crate) fn null_tracer() -> Option<impl Fn(&InflationPointCalculationEvent)> {
44    None::<fn(&_)>
45}
46
47#[derive(Debug)]
48pub enum SkippedReason {
49    DisabledInflation,
50    JustActivated,
51    TooEarlyUnfairSplit,
52    ZeroPoints,
53    ZeroPointValue,
54    ZeroReward,
55    ZeroCreditsAndReturnZero,
56    ZeroCreditsAndReturnCurrent,
57    ZeroCreditsAndReturnRewinded,
58}
59
60impl From<SkippedReason> for InflationPointCalculationEvent {
61    fn from(reason: SkippedReason) -> Self {
62        InflationPointCalculationEvent::Skipped(reason)
63    }
64}
65
66// utility function, used by runtime
67#[doc(hidden)]
68pub fn calculate_points(
69    stake_state: &StakeStateV2,
70    vote_state: &VoteState,
71    stake_history: &StakeHistory,
72    new_rate_activation_epoch: Option<Epoch>,
73) -> Result<u128, InstructionError> {
74    if let StakeStateV2::Stake(_meta, stake, _stake_flags) = stake_state {
75        Ok(calculate_stake_points(
76            stake,
77            vote_state,
78            stake_history,
79            null_tracer(),
80            new_rate_activation_epoch,
81        ))
82    } else {
83        Err(InstructionError::InvalidAccountData)
84    }
85}
86
87fn calculate_stake_points(
88    stake: &Stake,
89    vote_state: &VoteState,
90    stake_history: &StakeHistory,
91    inflation_point_calc_tracer: Option<impl Fn(&InflationPointCalculationEvent)>,
92    new_rate_activation_epoch: Option<Epoch>,
93) -> u128 {
94    calculate_stake_points_and_credits(
95        stake,
96        vote_state,
97        stake_history,
98        inflation_point_calc_tracer,
99        new_rate_activation_epoch,
100    )
101    .points
102}
103
104/// for a given stake and vote_state, calculate how many
105///   points were earned (credits * stake) and new value
106///   for credits_observed were the points paid
107pub(crate) fn calculate_stake_points_and_credits(
108    stake: &Stake,
109    new_vote_state: &VoteState,
110    stake_history: &StakeHistory,
111    inflation_point_calc_tracer: Option<impl Fn(&InflationPointCalculationEvent)>,
112    new_rate_activation_epoch: Option<Epoch>,
113) -> CalculatedStakePoints {
114    let credits_in_stake = stake.credits_observed;
115    let credits_in_vote = new_vote_state.credits();
116    // if there is no newer credits since observed, return no point
117    match credits_in_vote.cmp(&credits_in_stake) {
118        Ordering::Less => {
119            if let Some(inflation_point_calc_tracer) = inflation_point_calc_tracer.as_ref() {
120                inflation_point_calc_tracer(&SkippedReason::ZeroCreditsAndReturnRewinded.into());
121            }
122            // Don't adjust stake.activation_epoch for simplicity:
123            //  - generally fast-forwarding stake.activation_epoch forcibly (for
124            //    artificial re-activation with re-warm-up) skews the stake
125            //    history sysvar. And properly handling all the cases
126            //    regarding deactivation epoch/warm-up/cool-down without
127            //    introducing incentive skew is hard.
128            //  - Conceptually, it should be acceptable for the staked SOLs at
129            //    the recreated vote to receive rewards again immediately after
130            //    rewind even if it looks like instant activation. That's
131            //    because it must have passed the required warmed-up at least
132            //    once in the past already
133            //  - Also such a stake account remains to be a part of overall
134            //    effective stake calculation even while the vote account is
135            //    missing for (indefinite) time or remains to be pre-remove
136            //    credits score. It should be treated equally to staking with
137            //    delinquent validator with no differentiation.
138
139            // hint with true to indicate some exceptional credits handling is needed
140            return CalculatedStakePoints {
141                points: 0,
142                new_credits_observed: credits_in_vote,
143                force_credits_update_with_skipped_reward: true,
144            };
145        }
146        Ordering::Equal => {
147            if let Some(inflation_point_calc_tracer) = inflation_point_calc_tracer.as_ref() {
148                inflation_point_calc_tracer(&SkippedReason::ZeroCreditsAndReturnCurrent.into());
149            }
150            // don't hint caller and return current value if credits remain unchanged (= delinquent)
151            return CalculatedStakePoints {
152                points: 0,
153                new_credits_observed: credits_in_stake,
154                force_credits_update_with_skipped_reward: false,
155            };
156        }
157        Ordering::Greater => {}
158    }
159
160    let mut points = 0;
161    let mut new_credits_observed = credits_in_stake;
162
163    for (epoch, final_epoch_credits, initial_epoch_credits) in
164        new_vote_state.epoch_credits().iter().copied()
165    {
166        let stake_amount = u128::from(stake.delegation.stake(
167            epoch,
168            stake_history,
169            new_rate_activation_epoch,
170        ));
171
172        // figure out how much this stake has seen that
173        //   for which the vote account has a record
174        let earned_credits = if credits_in_stake < initial_epoch_credits {
175            // the staker observed the entire epoch
176            final_epoch_credits - initial_epoch_credits
177        } else if credits_in_stake < final_epoch_credits {
178            // the staker registered sometime during the epoch, partial credit
179            final_epoch_credits - new_credits_observed
180        } else {
181            // the staker has already observed or been redeemed this epoch
182            //  or was activated after this epoch
183            0
184        };
185        let earned_credits = u128::from(earned_credits);
186
187        // don't want to assume anything about order of the iterator...
188        new_credits_observed = new_credits_observed.max(final_epoch_credits);
189
190        // finally calculate points for this epoch
191        let earned_points = stake_amount * earned_credits;
192        points += earned_points;
193
194        if let Some(inflation_point_calc_tracer) = inflation_point_calc_tracer.as_ref() {
195            inflation_point_calc_tracer(&InflationPointCalculationEvent::CalculatedPoints(
196                epoch,
197                stake_amount,
198                earned_credits,
199                earned_points,
200            ));
201        }
202    }
203
204    CalculatedStakePoints {
205        points,
206        new_credits_observed,
207        force_credits_update_with_skipped_reward: false,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use {super::*, crate::stake_state::new_stake, solana_native_token::sol_to_lamports};
214
215    #[test]
216    fn test_stake_state_calculate_points_with_typical_values() {
217        let mut vote_state = VoteState::default();
218
219        // bootstrap means fully-vested stake at epoch 0 with
220        //  10_000_000 SOL is a big but not unreasaonable stake
221        let stake = new_stake(
222            sol_to_lamports(10_000_000f64),
223            &Pubkey::default(),
224            &vote_state,
225            u64::MAX,
226        );
227
228        let epoch_slots: u128 = 14 * 24 * 3600 * 160;
229        // put 193,536,000 credits in at epoch 0, typical for a 14-day epoch
230        //  this loop takes a few seconds...
231        for _ in 0..epoch_slots {
232            vote_state.increment_credits(0, 1);
233        }
234
235        // no overflow on points
236        assert_eq!(
237            u128::from(stake.delegation.stake) * epoch_slots,
238            calculate_stake_points(
239                &stake,
240                &vote_state,
241                &StakeHistory::default(),
242                null_tracer(),
243                None
244            )
245        );
246    }
247}