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