solana_stake_program/
points.rs

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