diff --git a/Cargo.lock b/Cargo.lock index c958a891..ec56230b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7205,6 +7205,7 @@ dependencies = [ "codama", "codama-macros", "num-traits", + "proptest", "serde", "serde_derive", "serde_json", diff --git a/clients/js/src/generated/types/delegation.ts b/clients/js/src/generated/types/delegation.ts index 418d22a6..c7fe36b3 100644 --- a/clients/js/src/generated/types/delegation.ts +++ b/clients/js/src/generated/types/delegation.ts @@ -10,12 +10,14 @@ import { combineCodec, getAddressDecoder, getAddressEncoder, - getF64Decoder, - getF64Encoder, + getArrayDecoder, + getArrayEncoder, getStructDecoder, getStructEncoder, getU64Decoder, getU64Encoder, + getU8Decoder, + getU8Encoder, type Address, type FixedSizeCodec, type FixedSizeDecoder, @@ -28,7 +30,7 @@ export type Delegation = { stake: bigint; activationEpoch: Epoch; deactivationEpoch: Epoch; - warmupCooldownRate: number; + reserved: Array; }; export type DelegationArgs = { @@ -36,7 +38,7 @@ export type DelegationArgs = { stake: number | bigint; activationEpoch: EpochArgs; deactivationEpoch: EpochArgs; - warmupCooldownRate: number; + reserved: Array; }; export function getDelegationEncoder(): FixedSizeEncoder { @@ -45,7 +47,7 @@ export function getDelegationEncoder(): FixedSizeEncoder { ['stake', getU64Encoder()], ['activationEpoch', getEpochEncoder()], ['deactivationEpoch', getEpochEncoder()], - ['warmupCooldownRate', getF64Encoder()], + ['reserved', getArrayEncoder(getU8Encoder(), { size: 8 })], ]); } @@ -55,7 +57,7 @@ export function getDelegationDecoder(): FixedSizeDecoder { ['stake', getU64Decoder()], ['activationEpoch', getEpochDecoder()], ['deactivationEpoch', getEpochDecoder()], - ['warmupCooldownRate', getF64Decoder()], + ['reserved', getArrayDecoder(getU8Decoder(), { size: 8 })], ]); } diff --git a/clients/rust/src/generated/types/delegation.rs b/clients/rust/src/generated/types/delegation.rs index 67195f5d..7b5123c4 100644 --- a/clients/rust/src/generated/types/delegation.rs +++ b/clients/rust/src/generated/types/delegation.rs @@ -22,5 +22,5 @@ pub struct Delegation { pub stake: u64, pub activation_epoch: Epoch, pub deactivation_epoch: Epoch, - pub warmup_cooldown_rate: f64, + pub reserved: [u8; 8], } diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 897b00d1..41915039 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -42,6 +42,7 @@ serde_json = { version = "1.0", optional = true } anyhow = "1" assert_matches = "1.5.0" bincode = "1.3.3" +proptest = "1.10.0" serial_test = "3.4.0" solana-account = { version = "4.0.0", features = ["bincode"] } solana-borsh = "3.0.2" diff --git a/interface/idl.json b/interface/idl.json index d13167c7..751f59fc 100644 --- a/interface/idl.json +++ b/interface/idl.json @@ -464,11 +464,18 @@ }, { "kind": "structFieldTypeNode", - "name": "warmupCooldownRate", + "name": "reserved", "type": { - "endian": "le", - "format": "f64", - "kind": "numberTypeNode" + "count": { + "kind": "fixedCountNode", + "value": 8 + }, + "item": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "kind": "arrayTypeNode" } } ], diff --git a/interface/src/lib.rs b/interface/src/lib.rs index c0c064ff..eb506532 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -13,6 +13,9 @@ pub mod state; #[cfg(feature = "sysvar")] pub mod sysvar; pub mod tools; +#[cfg(test)] +mod ulp; +pub mod warmup_cooldown_allowance; #[cfg(feature = "codama")] use codama_macros::codama; diff --git a/interface/src/state.rs b/interface/src/state.rs index e7fe0310..af97c3f8 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -14,6 +14,9 @@ use { instruction::LockupArgs, stake_flags::StakeFlags, stake_history::{StakeHistoryEntry, StakeHistoryGetEntry}, + warmup_cooldown_allowance::{ + calculate_activation_allowance, calculate_deactivation_allowance, + }, }, solana_clock::{Clock, Epoch, UnixTimestamp}, solana_instruction::error::InstructionError, @@ -25,10 +28,19 @@ pub type StakeActivationStatus = StakeHistoryEntry; // Means that no more than RATE of current effective stake may be added or subtracted per // epoch. +#[deprecated( + since = "3.2.0", + note = "Use `warmup_cooldown_allowance::ORIGINAL_WARMUP_COOLDOWN_RATE_BPS` instead" +)] pub const DEFAULT_WARMUP_COOLDOWN_RATE: f64 = 0.25; +#[deprecated( + since = "3.2.0", + note = "Use `warmup_cooldown_allowance::TOWER_WARMUP_COOLDOWN_RATE_BPS` instead" +)] pub const NEW_WARMUP_COOLDOWN_RATE: f64 = 0.09; pub const DEFAULT_SLASH_PENALTY: u8 = ((5 * u8::MAX as usize) / 100) as u8; +#[deprecated(since = "3.2.0", note = "Use warmup_cooldown_rate_bps() instead")] pub fn warmup_cooldown_rate(current_epoch: Epoch, new_rate_activation_epoch: Option) -> f64 { if current_epoch < new_rate_activation_epoch.unwrap_or(u64::MAX) { DEFAULT_WARMUP_COOLDOWN_RATE @@ -493,23 +505,19 @@ pub struct Delegation { pub activation_epoch: Epoch, /// epoch the stake was deactivated, `std::u64::MAX` if not deactivated pub deactivation_epoch: Epoch, - /// how much stake we can activate per-epoch as a fraction of currently effective stake - #[deprecated( - since = "1.16.7", - note = "Please use `solana_sdk::stake::state::warmup_cooldown_rate()` instead" - )] - pub warmup_cooldown_rate: f64, + /// Formerly the `warmup_cooldown_rate: f64`, but floats are not eBPF-compatible. + /// It is unused, but this field is now reserved to maintain layout compatibility. + pub _reserved: [u8; 8], } impl Default for Delegation { fn default() -> Self { - #[allow(deprecated)] Self { voter_pubkey: Pubkey::default(), stake: 0, activation_epoch: 0, deactivation_epoch: u64::MAX, - warmup_cooldown_rate: DEFAULT_WARMUP_COOLDOWN_RATE, + _reserved: [0; 8], } } } @@ -527,6 +535,9 @@ impl Delegation { self.activation_epoch == u64::MAX } + /// Previous implementation that uses floats under the hood to calculate warmup/cooldown + /// rate-limiting. New `stake_v2()` uses integers (upstream eBPF-compatible). + #[deprecated(since = "3.2.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch, @@ -537,7 +548,12 @@ impl Delegation { .effective } - #[allow(clippy::comparison_chain)] + /// Previous implementation that uses floats under the hood to calculate warmup/cooldown + /// rate-limiting. New `stake_activating_and_deactivating_v2()` uses integers (upstream eBPF-compatible). + #[deprecated( + since = "3.2.0", + note = "Use stake_activating_and_deactivating_v2() instead" + )] pub fn stake_activating_and_deactivating( &self, target_epoch: Epoch, @@ -625,6 +641,7 @@ impl Delegation { } // returned tuple is (effective, activating) stake + #[deprecated(since = "3.2.0", note = "Use stake_and_activating_v2() instead")] fn stake_and_activating( &self, target_epoch: Epoch, @@ -710,6 +727,199 @@ impl Delegation { (delegated_stake, 0) } } + + pub fn stake_v2( + &self, + epoch: Epoch, + history: &T, + new_rate_activation_epoch: Option, + ) -> u64 { + self.stake_activating_and_deactivating_v2(epoch, history, new_rate_activation_epoch) + .effective + } + + pub fn stake_activating_and_deactivating_v2( + &self, + target_epoch: Epoch, + history: &T, + new_rate_activation_epoch: Option, + ) -> StakeActivationStatus { + // first, calculate an effective and activating stake + let (effective_stake, activating_stake) = + self.stake_and_activating_v2(target_epoch, history, new_rate_activation_epoch); + + // then de-activate some portion if necessary + if target_epoch < self.deactivation_epoch { + // not deactivated + if activating_stake == 0 { + StakeActivationStatus::with_effective(effective_stake) + } else { + StakeActivationStatus::with_effective_and_activating( + effective_stake, + activating_stake, + ) + } + } else if target_epoch == self.deactivation_epoch { + // can only deactivate what's activated + StakeActivationStatus::with_deactivating(effective_stake) + } else if let Some((history, mut prev_epoch, mut prev_cluster_stake)) = history + .get_entry(self.deactivation_epoch) + .map(|cluster_stake_at_deactivation_epoch| { + ( + history, + self.deactivation_epoch, + cluster_stake_at_deactivation_epoch, + ) + }) + { + // target_epoch > self.deactivation_epoch + // + // We advance epoch-by-epoch from just after the deactivation epoch up to the target_epoch, + // removing (cooling down) the account's share of effective stake each epoch, + // potentially rate-limited by cluster history. + + let mut current_epoch; + let mut remaining_deactivating_stake = effective_stake; + loop { + current_epoch = prev_epoch + 1; + // if there is no deactivating stake at prev epoch, we should have been + // fully undelegated at this moment + if prev_cluster_stake.deactivating == 0 { + break; + } + + // Compute how much of this account's stake cools down in `current_epoch` + let newly_deactivated_stake = calculate_deactivation_allowance( + current_epoch, + remaining_deactivating_stake, + &prev_cluster_stake, + new_rate_activation_epoch, + ); + + // Subtract the newly deactivated stake, clamping the per-epoch decrease to at + // least 1 lamport so cooldown always makes progress + remaining_deactivating_stake = + remaining_deactivating_stake.saturating_sub(newly_deactivated_stake.max(1)); + + // Stop if we've fully cooled down this account + if remaining_deactivating_stake == 0 { + break; + } + + // Stop when we've reached the time bound for this query + if current_epoch >= target_epoch { + break; + } + + // Advance to the next epoch if we have history, otherwise we can't model further cooldown + if let Some(current_cluster_stake) = history.get_entry(current_epoch) { + prev_epoch = current_epoch; + prev_cluster_stake = current_cluster_stake; + } else { + // No more history data, return the best-effort state as of the last known epoch + break; + } + } + + // Report how much stake remains in cooldown at `target_epoch` + StakeActivationStatus::with_deactivating(remaining_deactivating_stake) + } else { + // no history or I've dropped out of history, so assume fully deactivated + StakeActivationStatus::default() + } + } + + // returned tuple is (effective, activating) stake + fn stake_and_activating_v2( + &self, + target_epoch: Epoch, + history: &T, + new_rate_activation_epoch: Option, + ) -> (u64, u64) { + let delegated_stake = self.stake; + + if self.is_bootstrap() { + // fully effective immediately + (delegated_stake, 0) + } else if self.activation_epoch == self.deactivation_epoch { + // activated but instantly deactivated; no stake at all regardless of target_epoch + // this must be after the bootstrap check and before all-is-activating check + (0, 0) + } else if target_epoch == self.activation_epoch { + // all is activating + (0, delegated_stake) + } else if target_epoch < self.activation_epoch { + // not yet enabled + (0, 0) + } else if let Some((history, mut prev_epoch, mut prev_cluster_stake)) = history + .get_entry(self.activation_epoch) + .map(|cluster_stake_at_activation_epoch| { + ( + history, + self.activation_epoch, + cluster_stake_at_activation_epoch, + ) + }) + { + // target_epoch > self.activation_epoch + // + // We advance epoch-by-epoch from just after the activation epoch up to the target_epoch, + // accumulating (warming up) the account's share of effective stake each epoch, + // potentially rate-limited by cluster history. + + let mut current_epoch; + let mut activated_stake_amount = 0; + loop { + current_epoch = prev_epoch + 1; + // if there is no activating stake at prev epoch, we should have been + // fully effective at this moment + if prev_cluster_stake.activating == 0 { + break; + } + + // Calculate how much of this account's remaining stake becomes effective in `current_epoch`. + let remaining_activating_stake = delegated_stake - activated_stake_amount; + let newly_effective_stake = calculate_activation_allowance( + current_epoch, + remaining_activating_stake, + &prev_cluster_stake, + new_rate_activation_epoch, + ); + + // Add the newly effective stake, clamping the per-epoch increase to at least 1 lamport so warmup always makes progress + activated_stake_amount += newly_effective_stake.max(1); + + // Stop if we've fully warmed up this account's stake. + if activated_stake_amount >= delegated_stake { + activated_stake_amount = delegated_stake; + break; + } + + // Stop when we've reached the time bound for this query + if current_epoch >= target_epoch || current_epoch >= self.deactivation_epoch { + break; + } + + // Advance to the next epoch if we have history, otherwise we can't model further warmup + if let Some(current_cluster_stake) = history.get_entry(current_epoch) { + prev_epoch = current_epoch; + prev_cluster_stake = current_cluster_stake; + } else { + // No more history data, return the best-effort state as of the last known epoch + break; + } + } + + // Return the portion that has become effective and the portion still activating + ( + activated_stake_amount, + delegated_stake - activated_stake_amount, + ) + } else { + // no history or I've dropped out of history, so assume fully effective + (delegated_stake, 0) + } + } } #[repr(C)] @@ -733,6 +943,7 @@ pub struct Stake { } impl Stake { + #[deprecated(since = "3.2.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch, @@ -743,6 +954,16 @@ impl Stake { .stake(epoch, history, new_rate_activation_epoch) } + pub fn stake_v2( + &self, + epoch: Epoch, + history: &T, + new_rate_activation_epoch: Option, + ) -> u64 { + self.delegation + .stake_v2(epoch, history, new_rate_activation_epoch) + } + pub fn split( &mut self, remaining_stake_delta: u64, @@ -777,7 +998,7 @@ impl Stake { mod tests { use { super::*, - crate::stake_history::StakeHistory, + crate::{stake_history::StakeHistory, warmup_cooldown_allowance::warmup_cooldown_rate_bps}, assert_matches::assert_matches, bincode::serialize, solana_account::{state_traits::StateMut, AccountSharedData, ReadableAccount}, @@ -804,7 +1025,11 @@ mod tests { I: Iterator, { stakes.fold(StakeHistoryEntry::default(), |sum, stake| { - sum + stake.stake_activating_and_deactivating(epoch, history, new_rate_activation_epoch) + sum + stake.stake_activating_and_deactivating_v2( + epoch, + history, + new_rate_activation_epoch, + ) }) } @@ -1006,28 +1231,37 @@ mod tests { }; // save this off so stake.config.warmup_rate changes don't break this test - let increment = (1_000_f64 * warmup_cooldown_rate(0, None)) as u64; + let rate_bps = warmup_cooldown_rate_bps(0, None); + let increment = ((1_000u128 * rate_bps as u128) / 10_000) as u64; let mut stake_history = StakeHistory::default(); // assert that this stake follows step function if there's no history assert_eq!( - stake.stake_activating_and_deactivating(stake.activation_epoch, &stake_history, None), + stake.stake_activating_and_deactivating_v2( + stake.activation_epoch, + &stake_history, + None + ), StakeActivationStatus::with_effective_and_activating(0, stake.stake), ); for epoch in stake.activation_epoch + 1..stake.deactivation_epoch { assert_eq!( - stake.stake_activating_and_deactivating(epoch, &stake_history, None), + stake.stake_activating_and_deactivating_v2(epoch, &stake_history, None), StakeActivationStatus::with_effective(stake.stake), ); } // assert that this stake is full deactivating assert_eq!( - stake.stake_activating_and_deactivating(stake.deactivation_epoch, &stake_history, None), + stake.stake_activating_and_deactivating_v2( + stake.deactivation_epoch, + &stake_history, + None + ), StakeActivationStatus::with_deactivating(stake.stake), ); // assert that this stake is fully deactivated if there's no history assert_eq!( - stake.stake_activating_and_deactivating( + stake.stake_activating_and_deactivating_v2( stake.deactivation_epoch + 1, &stake_history, None @@ -1044,7 +1278,7 @@ mod tests { ); // assert that this stake is broken, because above setup is broken assert_eq!( - stake.stake_activating_and_deactivating(1, &stake_history, None), + stake.stake_activating_and_deactivating_v2(1, &stake_history, None), StakeActivationStatus::with_effective_and_activating(0, stake.stake), ); @@ -1059,7 +1293,7 @@ mod tests { ); // assert that this stake is broken, because above setup is broken assert_eq!( - stake.stake_activating_and_deactivating(2, &stake_history, None), + stake.stake_activating_and_deactivating_v2(2, &stake_history, None), StakeActivationStatus::with_effective_and_activating( increment, stake.stake - increment @@ -1078,7 +1312,7 @@ mod tests { ); // assert that this stake is broken, because above setup is broken assert_eq!( - stake.stake_activating_and_deactivating( + stake.stake_activating_and_deactivating_v2( stake.deactivation_epoch + 1, &stake_history, None, @@ -1097,7 +1331,7 @@ mod tests { ); // assert that this stake is broken, because above setup is broken assert_eq!( - stake.stake_activating_and_deactivating( + stake.stake_activating_and_deactivating_v2( stake.deactivation_epoch + 2, &stake_history, None, @@ -1167,7 +1401,7 @@ mod tests { assert_eq!( expected_stakes, (0..expected_stakes.len()) - .map(|epoch| stake.stake_activating_and_deactivating( + .map(|epoch| stake.stake_activating_and_deactivating_v2( epoch as u64, &stake_history, None, @@ -1298,7 +1532,7 @@ mod tests { let calculate_each_staking_status = |stake: &Delegation, epoch_count: usize| -> Vec<_> { (0..epoch_count) .map(|epoch| { - stake.stake_activating_and_deactivating(epoch as u64, &stake_history, None) + stake.stake_activating_and_deactivating_v2(epoch as u64, &stake_history, None) }) .collect::>() }; @@ -1375,6 +1609,7 @@ mod tests { let mut effective = base_stake; let other_activation = 100; let mut other_activations = vec![0]; + let rate_bps = warmup_cooldown_rate_bps(0, None); // Build a stake history where the test staker always consumes all of the available warm // up and cool down stake. However, simulate other stakers beginning to activate during @@ -1397,7 +1632,7 @@ mod tests { }, ); - let effective_rate_limited = (effective as f64 * warmup_cooldown_rate(0, None)) as u64; + let effective_rate_limited = ((effective as u128) * rate_bps as u128 / 10_000) as u64; if epoch < stake.deactivation_epoch { effective += effective_rate_limited.min(activating); other_activations.push(0); @@ -1418,7 +1653,7 @@ mod tests { (0, history.deactivating) }; assert_eq!( - stake.stake_activating_and_deactivating(epoch, &stake_history, None), + stake.stake_activating_and_deactivating_v2(epoch, &stake_history, None), StakeActivationStatus { effective: expected_stake, activating: expected_activating, @@ -1440,7 +1675,8 @@ mod tests { let epochs = 7; // make bootstrap stake smaller than warmup so warmup/cooldownn // increment is always smaller than 1 - let bootstrap = (warmup_cooldown_rate(0, None) * 100.0 / 2.0) as u64; + let rate_bps = warmup_cooldown_rate_bps(0, None); + let bootstrap = ((100u128 * rate_bps as u128) / (2u128 * 10_000)) as u64; let stake_history = create_stake_history_from_delegations(Some(bootstrap), 0..epochs, &delegations, None); let mut max_stake = 0; @@ -1449,7 +1685,7 @@ mod tests { for epoch in 0..epochs { let stake = delegations .iter() - .map(|delegation| delegation.stake(epoch, &stake_history, None)) + .map(|delegation| delegation.stake_v2(epoch, &stake_history, None)) .sum::(); max_stake = max_stake.max(stake); min_stake = min_stake.min(stake); @@ -1518,7 +1754,7 @@ mod tests { let mut prev_total_effective_stake = delegations .iter() - .map(|delegation| delegation.stake(0, &stake_history, new_rate_activation_epoch)) + .map(|delegation| delegation.stake_v2(0, &stake_history, new_rate_activation_epoch)) .sum::(); // uncomment and add ! for fun with graphing @@ -1527,7 +1763,7 @@ mod tests { let total_effective_stake = delegations .iter() .map(|delegation| { - delegation.stake(epoch, &stake_history, new_rate_activation_epoch) + delegation.stake_v2(epoch, &stake_history, new_rate_activation_epoch) }) .sum::(); @@ -1538,13 +1774,10 @@ mod tests { // (0..(total_effective_stake as usize / (delegations.len() * 5))).for_each(|_| eprint("#")); // eprintln(); - assert!( - delta - <= ((prev_total_effective_stake as f64 - * warmup_cooldown_rate(epoch, new_rate_activation_epoch)) - as u64) - .max(1) - ); + let rate_bps = warmup_cooldown_rate_bps(epoch, new_rate_activation_epoch); + let max_delta = + ((prev_total_effective_stake as u128) * rate_bps as u128 / 10_000) as u64; + assert!(delta <= max_delta.max(1)); prev_total_effective_stake = total_effective_stake; } @@ -1737,7 +1970,7 @@ mod tests { stake: u64::MAX, activation_epoch: Epoch::MAX, deactivation_epoch: Epoch::MAX, - warmup_cooldown_rate: f64::MAX, + _reserved: [0; 8], }, credits_observed: 1, }, @@ -1759,7 +1992,10 @@ mod tests { } mod deprecated { - use super::*; + use { + super::*, + static_assertions::{assert_eq_align, assert_eq_size}, + }; fn check_borsh_deserialization(stake: StakeState) { let serialized = serialize(&stake).unwrap(); @@ -1805,7 +2041,7 @@ mod tests { stake: u64::MAX, activation_epoch: Epoch::MAX, deactivation_epoch: Epoch::MAX, - warmup_cooldown_rate: f64::MAX, + _reserved: [0; 8], }, credits_observed: 1, }, @@ -1839,7 +2075,7 @@ mod tests { stake: u64::MAX, activation_epoch: Epoch::MAX, deactivation_epoch: Epoch::MAX, - warmup_cooldown_rate: f64::MAX, + _reserved: [0; 8], }, credits_observed: 1, }, @@ -1870,5 +2106,61 @@ mod tests { }) ); } + + /// Contains legacy struct definitions to verify memory layout compatibility. + mod legacy { + use super::*; + + #[derive(borsh::BorshSerialize, borsh::BorshDeserialize)] + #[borsh(crate = "borsh")] + pub struct Delegation { + pub voter_pubkey: Pubkey, + pub stake: u64, + pub activation_epoch: Epoch, + pub deactivation_epoch: Epoch, + pub warmup_cooldown_rate: f64, + } + } + + #[test] + fn test_delegation_struct_layout_compatibility() { + assert_eq_size!(Delegation, legacy::Delegation); + assert_eq_align!(Delegation, legacy::Delegation); + } + + #[test] + #[allow(clippy::used_underscore_binding)] + fn test_delegation_deserialization_from_legacy_format() { + let legacy_delegation = legacy::Delegation { + voter_pubkey: Pubkey::new_unique(), + stake: 12345, + activation_epoch: 10, + deactivation_epoch: 20, + warmup_cooldown_rate: NEW_WARMUP_COOLDOWN_RATE, + }; + + let serialized_data = borsh::to_vec(&legacy_delegation).unwrap(); + + // Deserialize into the new `Delegation` struct + let new_delegation = Delegation::try_from_slice(&serialized_data).unwrap(); + + // Assert that the fields are identical + assert_eq!(new_delegation.voter_pubkey, legacy_delegation.voter_pubkey); + assert_eq!(new_delegation.stake, legacy_delegation.stake); + assert_eq!( + new_delegation.activation_epoch, + legacy_delegation.activation_epoch + ); + assert_eq!( + new_delegation.deactivation_epoch, + legacy_delegation.deactivation_epoch + ); + + // Assert that the `reserved` bytes now contain the raw bits of the old f64 + assert_eq!( + new_delegation._reserved, + NEW_WARMUP_COOLDOWN_RATE.to_le_bytes() + ); + } } } diff --git a/interface/src/ulp.rs b/interface/src/ulp.rs new file mode 100644 index 00000000..04f96d08 --- /dev/null +++ b/interface/src/ulp.rs @@ -0,0 +1,106 @@ +//! Math utilities for calculating float/int differences + +/// Calculates the "Unit in the Last Place" (`ULP`) for a `u64` value, which is +/// the gap between adjacent `f64` values at that magnitude. We need this because +/// the prop test compares the integer vs float implementations. Past `2^53`, `f64` +/// can't represent every integer, so the float result can differ by a few `ULPs` +/// even when both are correct. `f64` facts: +/// - `f64` has 53 bits of precision (52 fraction bits plus an implicit leading 1). +/// - For integers `x < 2^53`, every integer is exactly representable (`ULP = 1`). +/// - At and above powers of two, spacing doubles: +/// `[2^53, 2^54) ULP = 2` +/// `[2^54, 2^55) ULP = 4` +/// `[2^55, 2^56) ULP = 8` +fn ulp_of_u64(magnitude: u64) -> u64 { + // Avoid the special zero case by forcing at least 1 + let magnitude_f64 = magnitude.max(1) as f64; + + // spacing to the next representable f64 + let spacing = magnitude_f64.next_up() - magnitude_f64; + + // Map back to integer units, clamp so we never return 0 + spacing.max(1.0) as u64 +} + +/// Compute an absolute tolerance for comparing the integer result to the +/// legacy `f64`-based implementation. +/// +/// Because the legacy path rounds multiple times before the final floor, +/// the integer result can differ from the float version by a small number +/// of `ULPs` ("Unit in the Last Place") even when both are "correct" for +/// their domain. +pub fn max_ulp_tolerance(candidate: u64, oracle: u64) -> u64 { + // Measure ULP at the larger magnitude of the two results + let mag = candidate.max(oracle); + + // Get the ULP spacing + let ulp = ulp_of_u64(mag); + + // Use a 4x ULP tolerance to account for precision error accumulation in the + // legacy `f64` impl: + // - Three `u64` to `f64` conversions + // - One division and two multiplications are rounded + // - The `as u64` cast truncates the final `f64` result + // + // Proptest confirmed these can accumulate to >3 ULPs, so 4x is a safe margin. + ulp.saturating_mul(4) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn ulp_standard_calc() { + assert_eq!(ulp_of_u64(0), 1); + assert_eq!(ulp_of_u64(1), 1); + assert_eq!(ulp_of_u64((1u64 << 53) - 1), 1); + assert_eq!(ulp_of_u64(1u64 << 53), 2); + assert_eq!(ulp_of_u64(u64::MAX), 4096); + } + + #[test] + fn tolerance_small_magnitudes_use_single_ulp() { + // For magnitudes < 2^53, ULP = 1, so tolerance = 4 * 1 = 4. + assert_eq!(max_ulp_tolerance(0, 0), 4); + assert_eq!(max_ulp_tolerance(0, 1), 4); + assert_eq!(max_ulp_tolerance((1u64 << 53) - 1, 1), 4); + } + + #[test] + fn tolerance_scales_with_magnitude_powers_of_two() { + // Around powers of two, ULP doubles each time, so tolerance (4 * ULP) doubles. + let below_2_53 = max_ulp_tolerance((1u64 << 53) - 1, 0); // ULP = 1 + let at_2_53 = max_ulp_tolerance(1u64 << 53, 0); // ULP = 2 + let at_2_54 = max_ulp_tolerance(1u64 << 54, 0); // ULP = 4 + let at_2_55 = max_ulp_tolerance(1u64 << 55, 0); // ULP = 8 + + assert_eq!(below_2_53, 4); // 4 * 1 + assert_eq!(at_2_53, 8); // 4 * 2 + assert_eq!(at_2_54, 16); // 4 * 4 + assert_eq!(at_2_55, 32); // 4 * 8 + } + + #[test] + fn tolerance_uses_larger_of_two_results_and_is_symmetric() { + let small = 1u64; + let large = 1u64 << 53; // where ULP jumps from 1 to 2 + + // order of (candidate, oracle) shouldn't matter + let ab = max_ulp_tolerance(small, large); + let ba = max_ulp_tolerance(large, small); + assert_eq!(ab, ba); + + // Using (large, large) should give the same tolerance, since it's based on max() + let big_only = max_ulp_tolerance(large, large); + assert_eq!(ab, big_only); + } + + #[test] + fn tolerance_at_u64_max_matches_expected_ulp() { + // From ulp_standard_calc: ulp_of_u64(u64::MAX) == 4096 + // So tolerance = 4 * 4096 = 16384 + assert_eq!(max_ulp_tolerance(u64::MAX, 0), 4096 * 4); + assert_eq!(max_ulp_tolerance(0, u64::MAX), 4096 * 4); + } +} diff --git a/interface/src/warmup_cooldown_allowance.rs b/interface/src/warmup_cooldown_allowance.rs new file mode 100644 index 00000000..88940f7b --- /dev/null +++ b/interface/src/warmup_cooldown_allowance.rs @@ -0,0 +1,426 @@ +use {crate::stake_history::StakeHistoryEntry, solana_clock::Epoch}; + +pub const BASIS_POINTS_PER_UNIT: u64 = 10_000; +pub const ORIGINAL_WARMUP_COOLDOWN_RATE_BPS: u64 = 2_500; // 25% +pub const TOWER_WARMUP_COOLDOWN_RATE_BPS: u64 = 900; // 9% + +#[inline] +pub fn warmup_cooldown_rate_bps(epoch: Epoch, rate_change_activation_epoch: Option) -> u64 { + if rate_change_activation_epoch.is_some_and(|activation| epoch >= activation) { + TOWER_WARMUP_COOLDOWN_RATE_BPS + } else { + ORIGINAL_WARMUP_COOLDOWN_RATE_BPS + } +} + +/// Calculates the potentially rate-limited stake warmup for a single account in the current epoch. +/// +/// This function allocates a share of the cluster's per-epoch activation allowance +/// proportional to the account's share of the previous epoch's total activating stake. +pub fn calculate_activation_allowance( + current_epoch: Epoch, + account_activating_stake: u64, + prev_epoch_cluster_state: &StakeHistoryEntry, + rate_change_activation_epoch: Option, +) -> u64 { + calculate_stake_change_allowance( + current_epoch, + account_activating_stake, + prev_epoch_cluster_state.activating, + prev_epoch_cluster_state.effective, + rate_change_activation_epoch, + ) +} + +/// Calculates the potentially rate-limited stake cooldown for a single account in the current epoch. +/// +/// This function allocates a share of the cluster's per-epoch deactivation allowance +/// proportional to the account's share of the previous epoch's total deactivating stake. +pub fn calculate_deactivation_allowance( + current_epoch: Epoch, + account_deactivating_stake: u64, + prev_epoch_cluster_state: &StakeHistoryEntry, + rate_change_activation_epoch: Option, +) -> u64 { + calculate_stake_change_allowance( + current_epoch, + account_deactivating_stake, + prev_epoch_cluster_state.deactivating, + prev_epoch_cluster_state.effective, + rate_change_activation_epoch, + ) +} + +/// Internal helper for the rate-limited stake change calculation. +fn calculate_stake_change_allowance( + epoch: Epoch, + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + rate_change_activation_epoch: Option, +) -> u64 { + // Early return if there's no stake to change (also prevents divide by zero) + if account_portion == 0 || cluster_portion == 0 || cluster_effective == 0 { + return 0; + } + + let rate_bps = warmup_cooldown_rate_bps(epoch, rate_change_activation_epoch); + + // Calculate this account's proportional share of the network-wide stake change allowance for the epoch. + // Formula: `change = (account_portion / cluster_portion) * (cluster_effective * rate)` + // Where: + // - `(account_portion / cluster_portion)` is this account's share of the pool. + // - `(cluster_effective * rate)` is the total network allowance for change this epoch. + // + // Re-arranged formula to maximize precision: + // `change = (account_portion * cluster_effective * rate_bps) / (cluster_portion * BASIS_POINTS_PER_UNIT)` + // + // Using `u128` for the intermediate calculations to prevent overflow. + // If the multiplication would overflow, we saturate to u128::MAX. This ensures + // that even in extreme edge cases, the rate-limiting invariant is maintained + // (fail-safe) rather than bypassing rate limits entirely (fail-open). + let numerator = (account_portion as u128) + .saturating_mul(cluster_effective as u128) + .saturating_mul(rate_bps as u128); + let denominator = (cluster_portion as u128).saturating_mul(BASIS_POINTS_PER_UNIT as u128); + + // Safe unwrap as denominator cannot be zero due to early return guards above + let delta = numerator.checked_div(denominator).unwrap(); + // The calculated delta can be larger than `account_portion` if the network's stake change + // allowance is greater than the total stake waiting to change. In this case, the account's + // entire portion is allowed to change. + delta.min(account_portion as u128) as u64 +} + +#[cfg(test)] +mod test { + #[allow(deprecated)] + use crate::state::{DEFAULT_WARMUP_COOLDOWN_RATE, NEW_WARMUP_COOLDOWN_RATE}; + use { + super::*, + crate::ulp::max_ulp_tolerance, + proptest::prelude::*, + test_case::{test_case, test_matrix}, + }; + + #[derive(Clone, Copy, Debug)] + enum Kind { + Activation, + Deactivation, + } + + impl Kind { + fn prev_epoch_cluster_state( + self, + cluster_portion: u64, + cluster_effective: u64, + ) -> StakeHistoryEntry { + match self { + Self::Activation => StakeHistoryEntry { + activating: cluster_portion, + effective: cluster_effective, + ..Default::default() + }, + Self::Deactivation => StakeHistoryEntry { + deactivating: cluster_portion, + effective: cluster_effective, + ..Default::default() + }, + } + } + + fn calculate_allowance( + self, + current_epoch: Epoch, + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + rate_change_activation_epoch: Option, + ) -> u64 { + let prev = self.prev_epoch_cluster_state(cluster_portion, cluster_effective); + match self { + Self::Activation => calculate_activation_allowance( + current_epoch, + account_portion, + &prev, + rate_change_activation_epoch, + ), + Self::Deactivation => calculate_deactivation_allowance( + current_epoch, + account_portion, + &prev, + rate_change_activation_epoch, + ), + } + } + } + + #[test_case(9, Some(10), ORIGINAL_WARMUP_COOLDOWN_RATE_BPS; "before activation epoch")] + #[test_case(10, Some(10), TOWER_WARMUP_COOLDOWN_RATE_BPS; "at activation epoch")] + #[test_case(11, Some(10), TOWER_WARMUP_COOLDOWN_RATE_BPS; "after activation epoch")] + #[test_case(123, None, ORIGINAL_WARMUP_COOLDOWN_RATE_BPS; "without activation epoch")] + #[test_case(0, Some(0), TOWER_WARMUP_COOLDOWN_RATE_BPS; "activation at epoch 0 uses new rate from genesis")] + #[test_case(u64::MAX, None, ORIGINAL_WARMUP_COOLDOWN_RATE_BPS; "None never activates even at u64::MAX")] + fn rate_bps_selects_expected( + epoch: Epoch, + rate_change_activation_epoch: Option, + expected_bps: u64, + ) { + assert_eq!( + warmup_cooldown_rate_bps(epoch, rate_change_activation_epoch), + expected_bps + ); + } + + #[test_matrix( + [Kind::Activation, Kind::Deactivation], + [(0, 1, 1), (1, 0, 1), (1, 1, 0)] + )] + fn zero_cases_return_zero(kind: Kind, zero_inputs: (u64, u64, u64)) { + let (account_portion, cluster_portion, cluster_effective) = zero_inputs; + let allowance = kind.calculate_allowance( + 0, + account_portion, + cluster_portion, + cluster_effective, + Some(0), + ); + assert_eq!(allowance, 0); + } + + #[test_case( + Kind::Activation, 99, Some(100), 100, 500, 1_000, 50; + "activation at previous rate" + )] + #[test_case( + Kind::Activation, 100, Some(100), 100, 500, 1_000, 18; + "activation at current rate" + )] + #[test_case( + Kind::Deactivation, 99, Some(100), 100, 500, 1_000, 50; + "deactivation at previous rate" + )] + #[test_case( + Kind::Deactivation, 100, Some(100), 100, 500, 1_000, 18; + "deactivation at current rate" + )] + fn basic_proportional_allowance_matches_expected( + kind: Kind, + current_epoch: Epoch, + rate_change_activation_epoch: Option, + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + expected: u64, + ) { + // account share = 100 / 500 -> 1/5 + // old rate: 1_000 * 25% = 250, expected 50 + // new rate: 1_000 * 9% = 90, expected 18 + let result = kind.calculate_allowance( + current_epoch, + account_portion, + cluster_portion, + cluster_effective, + rate_change_activation_epoch, + ); + assert_eq!(result, expected); + } + + #[test_case( + Kind::Activation, 99, 40, 100, 1_000_000, Some(100), 40; + "activation caps at account portion" + )] + #[test_case( + Kind::Deactivation, 0, 70, 100, 1_000_000, None, 70; + "deactivation caps at account portion" + )] + fn allowance_caps_at_account_portion_when_network_allowance_is_large( + kind: Kind, + current_epoch: Epoch, + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + rate_change_activation_epoch: Option, + expected: u64, + ) { + // Total network allowance is enormous relative to waiting stake, + // so the result should clamp to the account portion. + let result = kind.calculate_allowance( + current_epoch, + account_portion, + cluster_portion, + cluster_effective, + rate_change_activation_epoch, + ); + assert_eq!(result, expected); + } + + #[test_case(Kind::Activation)] + #[test_case(Kind::Deactivation)] + fn overflow_scenario_still_rate_limits(kind: Kind) { + // Extreme scenario where a single account holding nearly the total supply + // and tries to change everything at once. Asserting rate limiting is maintained. + let supply_lamports: u64 = 400_000_000_000_000_000; // 400M SOL + let account_portion = supply_lamports; + + let actual_result = kind.calculate_allowance( + 100, + account_portion, + supply_lamports, + supply_lamports, + None, // forces 25% rate + ); + + // Verify overflow actually occurs in this scenario + let rate_bps = ORIGINAL_WARMUP_COOLDOWN_RATE_BPS; + let would_overflow = (account_portion as u128) + .checked_mul(supply_lamports as u128) + .and_then(|n| n.checked_mul(rate_bps as u128)) + .is_none(); + assert!(would_overflow); + + // The ideal result (with infinite precision) is 25% of the stake. + // 400M * 0.25 = 100M + let ideal_allowance = supply_lamports / 4; + + // With saturation fix: + // Numerator saturates to u128::MAX (≈ 3.4e38) + let numerator = (account_portion as u128) + .saturating_mul(supply_lamports as u128) + .saturating_mul(rate_bps as u128); + assert_eq!(numerator, u128::MAX); + + // Denominator = 4e17 * 10,000 = 4e21 + let denominator = (supply_lamports as u128).saturating_mul(BASIS_POINTS_PER_UNIT as u128); + assert_eq!(denominator, 4_000_000_000_000_000_000_000); + + // Result = u128::MAX / 4e21 ≈ 8.5e16 (~85M SOL) + // 85M is ~21.25% of the stake (fail-safe) + // If we allowed unlocking the full account portion it would have been 100% (fail-open) + let expected_result = numerator + .checked_div(denominator) + .unwrap() + .min(account_portion as u128) as u64; + assert_eq!(expected_result, 85_070_591_730_234_615); + + // Assert actual result is expected + assert_eq!(actual_result, expected_result); + assert!(actual_result < account_portion); + assert!(actual_result <= ideal_allowance); + } + + #[test] + fn integer_division_truncation_matches_expected() { + // Float math would yield 90.009, integer math must truncate to 90 + let account_portion = 100; + let cluster_portion = 1000; + let cluster_effective = 10001; + let epoch = 20; + let rate_change_activation_epoch = Some(10); // current 9/100 + + let result = calculate_stake_change_allowance( + epoch, + account_portion, + cluster_portion, + cluster_effective, + rate_change_activation_epoch, + ); + assert_eq!(result, 90); + } + + // === Legacy parity: compare the integer refactor vs legacy `f64` === + + #[allow(deprecated)] + fn legacy_warmup_cooldown_rate( + current_epoch: Epoch, + rate_change_activation_epoch: Option, + ) -> f64 { + if current_epoch < rate_change_activation_epoch.unwrap_or(u64::MAX) { + DEFAULT_WARMUP_COOLDOWN_RATE + } else { + NEW_WARMUP_COOLDOWN_RATE + } + } + + // The original formula used prior to integer implementation + fn calculate_stake_delta_f64_legacy( + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + current_epoch: Epoch, + rate_change_activation_epoch: Option, + ) -> u64 { + if cluster_portion == 0 || account_portion == 0 || cluster_effective == 0 { + return 0; + } + let weight = account_portion as f64 / cluster_portion as f64; + let rate = legacy_warmup_cooldown_rate(current_epoch, rate_change_activation_epoch); + let newly_effective_cluster_stake = cluster_effective as f64 * rate; + (weight * newly_effective_cluster_stake) as u64 + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + #[test] + fn rate_limited_change_consistent_with_legacy( + account_portion in 0u64..=u64::MAX, + cluster_portion in 0u64..=u64::MAX, + cluster_effective in 0u64..=u64::MAX, + current_epoch in 0u64..=2000, + rate_change_activation_epoch_option in prop::option::of(0u64..=2000), + ) { + let integer_math_result = calculate_stake_change_allowance( + current_epoch, + account_portion, + cluster_portion, + cluster_effective, + rate_change_activation_epoch_option, + ); + + let float_math_result = calculate_stake_delta_f64_legacy( + account_portion, + cluster_portion, + cluster_effective, + current_epoch, + rate_change_activation_epoch_option, + ).min(account_portion); + + let rate_bps = + warmup_cooldown_rate_bps(current_epoch, rate_change_activation_epoch_option); + + // See if the u128 product would overflow: account * effective * rate_bps + let would_overflow = (account_portion as u128) + .checked_mul(cluster_effective as u128) + .and_then(|n| n.checked_mul(rate_bps as u128)) + .is_none(); + + if account_portion == 0 || cluster_portion == 0 || cluster_effective == 0 { + prop_assert_eq!(integer_math_result, 0); + prop_assert_eq!(float_math_result, 0); + } else if would_overflow { + // In the overflow path, the helper saturates the numerator to `u128::MAX`, + // then divides and clamps to `account_portion`. + let denominator = (cluster_portion as u128) + .checked_mul(BASIS_POINTS_PER_UNIT as u128) + .unwrap(); + let saturated_result = u128::MAX + .checked_div(denominator) + .unwrap() + .min(account_portion as u128) as u64; + prop_assert_eq!(integer_math_result, saturated_result); + } else { + prop_assert!(integer_math_result <= account_portion); + prop_assert!(float_math_result <= account_portion); + + let diff = integer_math_result.abs_diff(float_math_result); + let tolerance = max_ulp_tolerance(integer_math_result, float_math_result); + prop_assert!( + diff <= tolerance, + "Test failed: candidate={}, oracle={}, diff={}, tolerance={}", + integer_math_result, float_math_result, diff, tolerance + ); + } + } + } +} diff --git a/program/src/helpers/merge.rs b/program/src/helpers/merge.rs index 6b155d99..ee652221 100644 --- a/program/src/helpers/merge.rs +++ b/program/src/helpers/merge.rs @@ -43,7 +43,7 @@ impl MergeKind { StakeStateV2::Stake(meta, stake, stake_flags) => { // stake must not be in a transient state. Transient here meaning // activating or deactivating with non-zero effective stake. - let status = stake.delegation.stake_activating_and_deactivating( + let status = stake.delegation.stake_activating_and_deactivating_v2( clock.epoch, stake_history, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, @@ -237,7 +237,10 @@ mod tests { solana_account::{state_traits::StateMut, AccountSharedData, ReadableAccount}, solana_pubkey::Pubkey, solana_rent::Rent, - solana_stake_interface::stake_history::{StakeHistory, StakeHistoryEntry}, + solana_stake_interface::{ + stake_history::{StakeHistory, StakeHistoryEntry}, + warmup_cooldown_allowance::warmup_cooldown_rate_bps, + }, }; #[test] @@ -535,10 +538,9 @@ mod tests { // all paritially activated, transient epochs fail loop { clock.epoch += 1; - let delta = activating.min( - (effective as f64 * warmup_cooldown_rate(clock.epoch, new_rate_activation_epoch)) - as u64, - ); + let rate_bps = warmup_cooldown_rate_bps(clock.epoch, new_rate_activation_epoch); + let rate_limited = ((effective as u128) * rate_bps as u128 / 10_000) as u64; + let delta = activating.min(rate_limited); effective += delta; activating -= delta; stake_history.add( @@ -612,10 +614,9 @@ mod tests { // all transient, deactivating epochs fail loop { clock.epoch += 1; - let delta = deactivating.min( - (effective as f64 * warmup_cooldown_rate(clock.epoch, new_rate_activation_epoch)) - as u64, - ); + let rate_bps = warmup_cooldown_rate_bps(clock.epoch, new_rate_activation_epoch); + let rate_limited = ((effective as u128) * rate_bps as u128 / 10_000) as u64; + let delta = deactivating.min(rate_limited); effective -= delta; deactivating -= delta; stake_history.add( diff --git a/program/src/processor.rs b/program/src/processor.rs index 53bd2711..b603a5f7 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -444,7 +444,7 @@ impl Processor { validate_delegated_amount(stake_account_info, rent_exempt_reserve)?; // Get current activation status at this epoch - let effective_stake = stake.delegation.stake( + let effective_stake = stake.delegation.stake_v2( clock.epoch, stake_history, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, @@ -537,11 +537,13 @@ impl Processor { .check(&signers, StakeAuthorize::Staker) .map_err(to_program_error)?; - let source_status = source_stake.delegation.stake_activating_and_deactivating( - clock.epoch, - stake_history, - PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, - ); + let source_status = source_stake + .delegation + .stake_activating_and_deactivating_v2( + clock.epoch, + stake_history, + PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, + ); let is_active_or_activating = source_status.effective > 0 || source_status.activating > 0; @@ -771,7 +773,7 @@ impl Processor { .map_err(to_program_error)?; // if we have a deactivation epoch and we're in cooldown let staked = if clock.epoch >= stake.delegation.deactivation_epoch { - stake.delegation.stake( + stake.delegation.stake_v2( clock.epoch, stake_history, PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH, diff --git a/program/tests/interface.rs b/program/tests/interface.rs index f6a6a877..827404b9 100644 --- a/program/tests/interface.rs +++ b/program/tests/interface.rs @@ -17,9 +17,9 @@ use { instruction::{self, LockupArgs}, stake_flags::StakeFlags, stake_history::StakeHistory, - state::{ - warmup_cooldown_rate, Authorized, Delegation, Lockup, Meta, Stake, StakeAuthorize, - StakeStateV2, NEW_WARMUP_COOLDOWN_RATE, + state::{Authorized, Delegation, Lockup, Meta, Stake, StakeAuthorize, StakeStateV2}, + warmup_cooldown_allowance::{ + warmup_cooldown_rate_bps, BASIS_POINTS_PER_UNIT, TOWER_WARMUP_COOLDOWN_RATE_BPS, }, }, solana_stake_interface_v2::stake_history::StakeHistoryEntry as MolluskStakeHistoryEntry, @@ -105,7 +105,10 @@ const CUSTODIAN_RIGHT: Pubkey = const PERSISTENT_ACTIVE_STAKE: u64 = 100 * LAMPORTS_PER_SOL; #[test] fn assert_warmup_cooldown_rate() { - assert_eq!(warmup_cooldown_rate(0, Some(0)), NEW_WARMUP_COOLDOWN_RATE); + assert_eq!( + warmup_cooldown_rate_bps(0, Some(0)), + TOWER_WARMUP_COOLDOWN_RATE_BPS + ); } // this mirrors the false const for `Meta.rent_exempt_reserve` in the stake program @@ -163,7 +166,7 @@ impl Env { // backfill stake history let stake_delta_amount = - (PERSISTENT_ACTIVE_STAKE as f64 * NEW_WARMUP_COOLDOWN_RATE).floor() as u64; + PERSISTENT_ACTIVE_STAKE * TOWER_WARMUP_COOLDOWN_RATE_BPS / BASIS_POINTS_PER_UNIT; for epoch in 0..EXECUTION_EPOCH { mollusk.sysvars.stake_history.add( epoch, diff --git a/program/tests/program_test.rs b/program/tests/program_test.rs index 51b68e33..ffcf6cd0 100644 --- a/program/tests/program_test.rs +++ b/program/tests/program_test.rs @@ -202,7 +202,7 @@ pub async fn get_effective_stake(banks_client: &mut BanksClient, pubkey: &Pubkey StakeStateV2::Stake(_, stake, _) => { stake .delegation - .stake_activating_and_deactivating(clock.epoch, &stake_history, Some(0)) + .stake_activating_and_deactivating_v2(clock.epoch, &stake_history, Some(0)) .effective } _ => 0, diff --git a/program/tests/stake_instruction.rs b/program/tests/stake_instruction.rs index 96e3b9a1..f4beaa31 100644 --- a/program/tests/stake_instruction.rs +++ b/program/tests/stake_instruction.rs @@ -27,10 +27,8 @@ use { }, stake_flags::StakeFlags, stake_history::{StakeHistory, StakeHistoryEntry}, - state::{ - warmup_cooldown_rate, Authorized, Delegation, Lockup, Meta, Stake, StakeAuthorize, - StakeStateV2, - }, + state::{Authorized, Delegation, Lockup, Meta, Stake, StakeAuthorize, StakeStateV2}, + warmup_cooldown_allowance::warmup_cooldown_rate_bps, MINIMUM_DELINQUENT_EPOCHS_FOR_DEACTIVATION, }, solana_stake_program::{get_minimum_delegation, id}, @@ -234,7 +232,7 @@ fn get_active_stake_for_tests( let mut active_stake = 0; for account in stake_accounts { if let Ok(StakeStateV2::Stake(_meta, stake, _stake_flags)) = account.state() { - let stake_status = stake.delegation.stake_activating_and_deactivating( + let stake_status = stake.delegation.stake_activating_and_deactivating_v2( clock.epoch, stake_history, None, @@ -267,7 +265,7 @@ where I: Iterator, { stakes.fold(StakeHistoryEntry::default(), |sum, stake| { - sum + stake.stake_activating_and_deactivating(epoch, history, new_rate_activation_epoch) + sum + stake.stake_activating_and_deactivating_v2(epoch, history, new_rate_activation_epoch) }) } @@ -4325,7 +4323,7 @@ fn test_rescind_blocked_when_underfunded() { // to underfund the account (lamports < delegation.stake) let sh_acc = &tx_accts[5].1; let stake_history: StakeHistory = deserialize(sh_acc.data()).unwrap(); - let effective_stake = delegation_history.stake(2, &stake_history, Some(0)); + let effective_stake = delegation_history.stake_v2(2, &stake_history, Some(0)); let withdraw_amount = delegated_lamports - effective_stake; assert!(withdraw_amount > 0); @@ -6778,10 +6776,9 @@ fn test_merge_active_stake() { if clock.epoch == merge_from_activation_epoch { activating += merge_from_amount; } - let delta = activating.min( - (effective as f64 * warmup_cooldown_rate(clock.epoch, new_warmup_cooldown_rate_epoch)) - as u64, - ); + let rate_bps = warmup_cooldown_rate_bps(clock.epoch, new_warmup_cooldown_rate_epoch); + let rate_limited = ((effective as u128) * rate_bps as u128 / 10_000) as u64; + let delta = activating.min(rate_limited); effective += delta; activating -= delta; stake_history.add( @@ -6797,9 +6794,10 @@ fn test_merge_active_stake() { StakeHistory::id(), create_stake_history_account(&stake_history), ); - if stake_amount == stake.stake(clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch) + if stake_amount + == stake.stake_v2(clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch) && merge_from_amount - == merge_from_stake.stake( + == merge_from_stake.stake_v2( clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch, @@ -6830,10 +6828,9 @@ fn test_merge_active_stake() { // active/deactivating and deactivating/inactive mismatches fail loop { clock.epoch += 1; - let delta = deactivating.min( - (effective as f64 * warmup_cooldown_rate(clock.epoch, new_warmup_cooldown_rate_epoch)) - as u64, - ); + let rate_bps = warmup_cooldown_rate_bps(clock.epoch, new_warmup_cooldown_rate_epoch); + let rate_limited = ((effective as u128) * rate_bps as u128 / 10_000) as u64; + let delta = deactivating.min(rate_limited); effective -= delta; deactivating -= delta; if clock.epoch == stake_deactivation_epoch { @@ -6881,8 +6878,8 @@ fn test_merge_active_stake() { StakeHistory::id(), create_stake_history_account(&stake_history), ); - if 0 == stake.stake(clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch) - && 0 == merge_from_stake.stake( + if 0 == stake.stake_v2(clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch) + && 0 == merge_from_stake.stake_v2( clock.epoch, &stake_history, new_warmup_cooldown_rate_epoch, diff --git a/scripts/solana.dic b/scripts/solana.dic index 6c0dd4f2..70c0df24 100644 --- a/scripts/solana.dic +++ b/scripts/solana.dic @@ -15,6 +15,7 @@ pubkeys redelegate redelegated redelegation +representable rpc staker struct