solana_runtime/inflation_rewards/
points.rs

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