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