From 4276efe607d46b5aa9d9396e8d34b3ca7042e83c Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 31 Oct 2025 15:29:34 -0400 Subject: [PATCH 1/3] Convert warmup/cooldown rate to integers --- Cargo.lock | 1 + clients/js/src/generated/types/delegation.ts | 14 +- .../rust/src/generated/types/delegation.rs | 2 +- interface/Cargo.toml | 1 + interface/idl.json | 15 +- interface/src/lib.rs | 3 + interface/src/state.rs | 478 ++++++++++++++++-- interface/src/ulp.rs | 106 ++++ interface/src/warmup_cooldown_allowance.rs | 466 +++++++++++++++++ program/src/helpers/merge.rs | 21 +- program/src/processor.rs | 16 +- program/tests/interface.rs | 13 +- program/tests/program_test.rs | 2 +- program/tests/stake_instruction.rs | 35 +- scripts/solana.dic | 1 + 15 files changed, 1082 insertions(+), 92 deletions(-) create mode 100644 interface/src/ulp.rs create mode 100644 interface/src/warmup_cooldown_allowance.rs 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..426ea41f 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,16 @@ pub type StakeActivationStatus = StakeHistoryEntry; // Means that no more than RATE of current effective stake may be added or subtracted per // epoch. +#[deprecated( + since = "3.1.0", + note = "Use ORIGINAL_WARMUP_COOLDOWN_RATE_BPS instead" +)] pub const DEFAULT_WARMUP_COOLDOWN_RATE: f64 = 0.25; +#[deprecated(since = "3.1.0", note = "Use 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.1.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 +502,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 +532,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.1.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch, @@ -537,7 +545,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.1.0", + note = "Use stake_activating_and_deactivating_v2() instead" + )] pub fn stake_activating_and_deactivating( &self, target_epoch: Epoch, @@ -625,6 +638,7 @@ impl Delegation { } // returned tuple is (effective, activating) stake + #[deprecated(since = "3.1.0", note = "Use stake_and_activating_v2() instead")] fn stake_and_activating( &self, target_epoch: Epoch, @@ -710,6 +724,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 +940,7 @@ pub struct Stake { } impl Stake { + #[deprecated(since = "3.1.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch, @@ -743,6 +951,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 +995,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 +1022,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 +1228,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 +1275,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 +1290,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 +1309,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 +1328,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 +1398,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 +1529,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 +1606,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 +1629,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 +1650,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 +1672,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 +1682,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 +1751,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 +1760,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 +1771,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 +1967,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, }, @@ -1758,8 +1988,122 @@ mod tests { check_flag(StakeFlags::empty(), 0); } + #[cfg(test)] + #[allow(deprecated)] + mod delegation_prop_tests { + use { + super::*, + crate::{ + stake_history::{StakeHistory, StakeHistoryEntry}, + ulp::max_ulp_tolerance, + }, + proptest::prelude::*, + solana_pubkey::Pubkey, + }; + + prop_compose! { + fn arbitrary_delegation()( + // This tests is bounded to the range where `f64` can represent every integer exactly. + // Beyond this, integer math and float math diverge considerably. + // This case is covered in `warmup_cooldown_allowance.rs`. + stake in 0u64..=(1u64 << 53) - 1, + activation_epoch in 0u64..=50, + deactivation_offset in 0u64..=50, + ) -> Delegation { + let deactivation_epoch = activation_epoch.saturating_add(deactivation_offset); + + Delegation { + voter_pubkey: Pubkey::new_unique(), + stake, + activation_epoch, + deactivation_epoch, + ..Delegation::default() + } + } + } + + prop_compose! { + fn arbitrary_stake_history(max_epoch: Epoch)( + entries in prop::collection::vec( + ( + 0u64..=max_epoch, + 0u64..=1_000_000_000_000, // effective + 0u64..=1_000_000_000_000, // activating + 0u64..=1_000_000_000_000, // deactivating + ), + 0..=((max_epoch + 1) as usize), + ) + ) -> StakeHistory { + let mut history = StakeHistory::default(); + for (epoch, effective, activating, deactivating) in entries { + history.add( + epoch, + StakeHistoryEntry { + effective, + activating, + deactivating, + }, + ); + } + history + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + #[test] + fn delegation_stake_matches_legacy_within_tolerance( + delegation in arbitrary_delegation(), + target_epoch in 0u64..=50, + new_rate_activation_epoch_option in prop::option::of(0u64..=50), + stake_history in arbitrary_stake_history(50), + ) { + let new_stake = delegation.stake_v2( + target_epoch, + &stake_history, + new_rate_activation_epoch_option, + ); + let legacy_stake = delegation.stake( + target_epoch, + &stake_history, + new_rate_activation_epoch_option, + ); + + // neither path should ever exceed the delegated amount. + prop_assert!(new_stake <= delegation.stake); + prop_assert!(legacy_stake <= delegation.stake); + + // If the delegation has no stake, both must be zero. + if delegation.stake == 0 { + prop_assert_eq!(new_stake, 0); + prop_assert_eq!(legacy_stake, 0); + } else { + // Compare with a ULP-based tolerance to account for float vs integer math. + let diff = new_stake.abs_diff(legacy_stake); + let tolerance = max_ulp_tolerance(new_stake, legacy_stake); + + prop_assert!( + diff <= tolerance, + "stake mismatch: new={}, legacy={}, diff={}, tol={}, delegation={:?}, target_epoch={}, new_rate_activation_epoch_option={:?}", + new_stake, + legacy_stake, + diff, + tolerance, + delegation, + target_epoch, + new_rate_activation_epoch_option, + ); + } + } + } + } + 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 +2149,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 +2183,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 +2214,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..8a225879 --- /dev/null +++ b/interface/src/warmup_cooldown_allowance.rs @@ -0,0 +1,466 @@ +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, new_rate_activation_epoch: Option) -> u64 { + if epoch < new_rate_activation_epoch.unwrap_or(u64::MAX) { + ORIGINAL_WARMUP_COOLDOWN_RATE_BPS + } else { + TOWER_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, + new_rate_activation_epoch: Option, +) -> u64 { + rate_limited_stake_change( + current_epoch, + account_activating_stake, + prev_epoch_cluster_state.activating, + prev_epoch_cluster_state.effective, + new_rate_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, + new_rate_activation_epoch: Option, +) -> u64 { + rate_limited_stake_change( + current_epoch, + account_deactivating_stake, + prev_epoch_cluster_state.deactivating, + prev_epoch_cluster_state.effective, + new_rate_activation_epoch, + ) +} + +/// Internal helper for the rate-limited stake change calculation. +fn rate_limited_stake_change( + epoch: Epoch, + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + new_rate_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, new_rate_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::*, std::ops::Div, + test_case::test_case, + }; + + // === Rate selector === + + #[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")] + fn rate_bps_selects_expected( + epoch: Epoch, + new_rate_activation_epoch: Option, + expected_bps: u64, + ) { + assert_eq!( + warmup_cooldown_rate_bps(epoch, new_rate_activation_epoch), + expected_bps + ); + } + + #[test_case(0, 10, 100; "account portion is zero")] + #[test_case(5, 0, 100; "cluster portion is zero")] + #[test_case(5, 10, 0; "cluster effective is zero")] + fn activation_zero_cases_return_zero( + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + ) { + let prev = StakeHistoryEntry { + activating: cluster_portion, + effective: cluster_effective, + ..Default::default() + }; + assert_eq!( + calculate_activation_allowance(0, account_portion, &prev, Some(0)), + 0 + ); + } + + #[test] + fn activation_overflow_scenario_still_rate_limits() { + // Extreme scenario where a single account holding nearly the total supply + // and tries to activate 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 prev = StakeHistoryEntry { + activating: supply_lamports, + effective: supply_lamports, + ..Default::default() + }; + + let actual_result = calculate_activation_allowance( + 100, + account_portion, + &prev, + 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.div(denominator).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 activation_basic_proportional_prev_rate() { + // cluster_effective = 1000, prev rate = 1/4 => total allowance = 250 + // account share = 100 / 500 -> 1/5 => expected 50 + let current_epoch = 99; + let new_rate_activation_epoch = Some(100); + let prev = StakeHistoryEntry { + activating: 500, + effective: 1000, + ..Default::default() + }; + let result = + calculate_activation_allowance(current_epoch, 100, &prev, new_rate_activation_epoch); + assert_eq!(result, 50); + } + + #[test] + fn activation_caps_at_account_portion_when_network_allowance_is_large() { + // total network allowance enormous relative to waiting stake, should cap to account_portion. + let current_epoch = 99; + let new_rate_activation_epoch = Some(100); // prev rate (1/4) + let prev = StakeHistoryEntry { + activating: 100, // cluster_portion + effective: 1_000_000, // large cluster effective + ..Default::default() + }; + let account_portion = 40; + let result = calculate_activation_allowance( + current_epoch, + account_portion, + &prev, + new_rate_activation_epoch, + ); + assert_eq!(result, account_portion); + } + + #[test_case(0, 10, 100; "account portion is zero")] + #[test_case(5, 0, 100; "cluster portion is zero")] + #[test_case(5, 10, 0; "cluster effective is zero")] + fn cooldown_zero_cases_return_zero( + account_portion: u64, + cluster_portion: u64, + cluster_effective: u64, + ) { + let prev = StakeHistoryEntry { + deactivating: cluster_portion, + effective: cluster_effective, + ..Default::default() + }; + assert_eq!( + calculate_deactivation_allowance(0, account_portion, &prev, Some(0)), + 0 + ); + } + + #[test] + fn cooldown_basic_proportional_curr_rate() { + // cluster_effective = 10_000, curr rate = 9/100 => total allowance = 900 + // account share = 200 / 1000 => expected 180 + let current_epoch = 5; + let new_rate_activation_epoch = Some(5); // current (epoch >= activation) + let prev = StakeHistoryEntry { + deactivating: 1000, + effective: 10_000, + ..Default::default() + }; + let result = + calculate_deactivation_allowance(current_epoch, 200, &prev, new_rate_activation_epoch); + assert_eq!(result, 180); + } + + #[test] + fn cooldown_overflow_scenario_still_rate_limits() { + // Extreme scenario where a single account holding nearly the total supply + // and tries to deactivate 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 prev = StakeHistoryEntry { + deactivating: supply_lamports, + effective: supply_lamports, + ..Default::default() + }; + + let actual_result = calculate_deactivation_allowance( + 100, + account_portion, + &prev, + 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.div(denominator).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 cooldown_caps_at_account_portion_when_network_allowance_is_large() { + let current_epoch = 0; + let new_rate_activation_epoch = None; // uses prev (1/4) + let prev = StakeHistoryEntry { + deactivating: 100, + effective: 1_000_000, + ..Default::default() + }; + let account_portion = 70; + let result = calculate_deactivation_allowance( + current_epoch, + account_portion, + &prev, + new_rate_activation_epoch, + ); + assert_eq!(result, account_portion); + } + + // === Symmetry & integer rounding === + + #[test] + fn activation_and_cooldown_are_symmetric_given_same_inputs() { + // With equal cluster_portions and same epoch/rate, the math should match. + let epoch = 42; + let new_rate_activation_epoch = Some(1_000); // prev rate for both calls + let prev = StakeHistoryEntry { + activating: 1_000, + deactivating: 1_000, + effective: 5_000, + }; + let account = 333; + let act = calculate_activation_allowance(epoch, account, &prev, new_rate_activation_epoch); + let cool = + calculate_deactivation_allowance(epoch, account, &prev, new_rate_activation_epoch); + assert_eq!(act, cool); + } + + #[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 new_rate_activation_epoch = Some(10); // current 9/100 + + let result = rate_limited_stake_change( + epoch, + account_portion, + cluster_portion, + cluster_effective, + new_rate_activation_epoch, + ); + assert_eq!(result, 90); + } + + // === Property tests: compare the integer refactor vs legacy `f64` === + + #[allow(deprecated)] + fn legacy_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 + } 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, + new_rate_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, new_rate_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, + new_rate_activation_epoch_option in prop::option::of(0u64..=2000), + ) { + let integer_math_result = rate_limited_stake_change( + current_epoch, + account_portion, + cluster_portion, + cluster_effective, + new_rate_activation_epoch_option, + ); + + let float_math_result = calculate_stake_delta_f64_legacy( + account_portion, + cluster_portion, + cluster_effective, + current_epoch, + new_rate_activation_epoch_option, + ).min(account_portion); + + let rate_bps = + warmup_cooldown_rate_bps(current_epoch, new_rate_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 numerator is `u128::MAX` and is divided then clamped to + // `account_portion`. This math often results in less than `account_portion`, but + // never should exceed it. It may be equal in the case the denominator is small and + // post-division result gets clamped. + prop_assert!(integer_math_result <= account_portion); + } 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 From a34dd8bb57c2721c22f47ab7763cfa69b117089a Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 16 Apr 2026 06:29:28 -0700 Subject: [PATCH 2/3] More test_case usage --- interface/src/state.rs | 113 +----- interface/src/warmup_cooldown_allowance.rs | 386 +++++++++------------ 2 files changed, 174 insertions(+), 325 deletions(-) diff --git a/interface/src/state.rs b/interface/src/state.rs index 426ea41f..f961d74c 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -1988,117 +1988,6 @@ mod tests { check_flag(StakeFlags::empty(), 0); } - #[cfg(test)] - #[allow(deprecated)] - mod delegation_prop_tests { - use { - super::*, - crate::{ - stake_history::{StakeHistory, StakeHistoryEntry}, - ulp::max_ulp_tolerance, - }, - proptest::prelude::*, - solana_pubkey::Pubkey, - }; - - prop_compose! { - fn arbitrary_delegation()( - // This tests is bounded to the range where `f64` can represent every integer exactly. - // Beyond this, integer math and float math diverge considerably. - // This case is covered in `warmup_cooldown_allowance.rs`. - stake in 0u64..=(1u64 << 53) - 1, - activation_epoch in 0u64..=50, - deactivation_offset in 0u64..=50, - ) -> Delegation { - let deactivation_epoch = activation_epoch.saturating_add(deactivation_offset); - - Delegation { - voter_pubkey: Pubkey::new_unique(), - stake, - activation_epoch, - deactivation_epoch, - ..Delegation::default() - } - } - } - - prop_compose! { - fn arbitrary_stake_history(max_epoch: Epoch)( - entries in prop::collection::vec( - ( - 0u64..=max_epoch, - 0u64..=1_000_000_000_000, // effective - 0u64..=1_000_000_000_000, // activating - 0u64..=1_000_000_000_000, // deactivating - ), - 0..=((max_epoch + 1) as usize), - ) - ) -> StakeHistory { - let mut history = StakeHistory::default(); - for (epoch, effective, activating, deactivating) in entries { - history.add( - epoch, - StakeHistoryEntry { - effective, - activating, - deactivating, - }, - ); - } - history - } - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(10_000))] - - #[test] - fn delegation_stake_matches_legacy_within_tolerance( - delegation in arbitrary_delegation(), - target_epoch in 0u64..=50, - new_rate_activation_epoch_option in prop::option::of(0u64..=50), - stake_history in arbitrary_stake_history(50), - ) { - let new_stake = delegation.stake_v2( - target_epoch, - &stake_history, - new_rate_activation_epoch_option, - ); - let legacy_stake = delegation.stake( - target_epoch, - &stake_history, - new_rate_activation_epoch_option, - ); - - // neither path should ever exceed the delegated amount. - prop_assert!(new_stake <= delegation.stake); - prop_assert!(legacy_stake <= delegation.stake); - - // If the delegation has no stake, both must be zero. - if delegation.stake == 0 { - prop_assert_eq!(new_stake, 0); - prop_assert_eq!(legacy_stake, 0); - } else { - // Compare with a ULP-based tolerance to account for float vs integer math. - let diff = new_stake.abs_diff(legacy_stake); - let tolerance = max_ulp_tolerance(new_stake, legacy_stake); - - prop_assert!( - diff <= tolerance, - "stake mismatch: new={}, legacy={}, diff={}, tol={}, delegation={:?}, target_epoch={}, new_rate_activation_epoch_option={:?}", - new_stake, - legacy_stake, - diff, - tolerance, - delegation, - target_epoch, - new_rate_activation_epoch_option, - ); - } - } - } - } - mod deprecated { use { super::*, @@ -2249,7 +2138,7 @@ mod tests { let serialized_data = borsh::to_vec(&legacy_delegation).unwrap(); - // Deserialize into the NEW Delegation struct + // Deserialize into the new `Delegation` struct let new_delegation = Delegation::try_from_slice(&serialized_data).unwrap(); // Assert that the fields are identical diff --git a/interface/src/warmup_cooldown_allowance.rs b/interface/src/warmup_cooldown_allowance.rs index 8a225879..88940f7b 100644 --- a/interface/src/warmup_cooldown_allowance.rs +++ b/interface/src/warmup_cooldown_allowance.rs @@ -5,11 +5,11 @@ 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, new_rate_activation_epoch: Option) -> u64 { - if epoch < new_rate_activation_epoch.unwrap_or(u64::MAX) { - ORIGINAL_WARMUP_COOLDOWN_RATE_BPS - } else { +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 } } @@ -21,14 +21,14 @@ pub fn calculate_activation_allowance( current_epoch: Epoch, account_activating_stake: u64, prev_epoch_cluster_state: &StakeHistoryEntry, - new_rate_activation_epoch: Option, + rate_change_activation_epoch: Option, ) -> u64 { - rate_limited_stake_change( + calculate_stake_change_allowance( current_epoch, account_activating_stake, prev_epoch_cluster_state.activating, prev_epoch_cluster_state.effective, - new_rate_activation_epoch, + rate_change_activation_epoch, ) } @@ -40,31 +40,31 @@ pub fn calculate_deactivation_allowance( current_epoch: Epoch, account_deactivating_stake: u64, prev_epoch_cluster_state: &StakeHistoryEntry, - new_rate_activation_epoch: Option, + rate_change_activation_epoch: Option, ) -> u64 { - rate_limited_stake_change( + calculate_stake_change_allowance( current_epoch, account_deactivating_stake, prev_epoch_cluster_state.deactivating, prev_epoch_cluster_state.effective, - new_rate_activation_epoch, + rate_change_activation_epoch, ) } /// Internal helper for the rate-limited stake change calculation. -fn rate_limited_stake_change( +fn calculate_stake_change_allowance( epoch: Epoch, account_portion: u64, cluster_portion: u64, cluster_effective: u64, - new_rate_activation_epoch: Option, + 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, new_rate_activation_epoch); + 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)` @@ -97,187 +97,177 @@ mod test { #[allow(deprecated)] use crate::state::{DEFAULT_WARMUP_COOLDOWN_RATE, NEW_WARMUP_COOLDOWN_RATE}; use { - super::*, crate::ulp::max_ulp_tolerance, proptest::prelude::*, std::ops::Div, - test_case::test_case, + super::*, + crate::ulp::max_ulp_tolerance, + proptest::prelude::*, + test_case::{test_case, test_matrix}, }; - // === Rate selector === + #[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, - new_rate_activation_epoch: Option, + rate_change_activation_epoch: Option, expected_bps: u64, ) { assert_eq!( - warmup_cooldown_rate_bps(epoch, new_rate_activation_epoch), + warmup_cooldown_rate_bps(epoch, rate_change_activation_epoch), expected_bps ); } - #[test_case(0, 10, 100; "account portion is zero")] - #[test_case(5, 0, 100; "cluster portion is zero")] - #[test_case(5, 10, 0; "cluster effective is zero")] - fn activation_zero_cases_return_zero( - account_portion: u64, - cluster_portion: u64, - cluster_effective: u64, - ) { - let prev = StakeHistoryEntry { - activating: cluster_portion, - effective: cluster_effective, - ..Default::default() - }; - assert_eq!( - calculate_activation_allowance(0, account_portion, &prev, Some(0)), - 0 - ); - } - - #[test] - fn activation_overflow_scenario_still_rate_limits() { - // Extreme scenario where a single account holding nearly the total supply - // and tries to activate 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 prev = StakeHistoryEntry { - activating: supply_lamports, - effective: supply_lamports, - ..Default::default() - }; - - let actual_result = calculate_activation_allowance( - 100, + #[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, - &prev, - None, // forces 25% rate + cluster_portion, + cluster_effective, + Some(0), ); - - // 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.div(denominator).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 activation_basic_proportional_prev_rate() { - // cluster_effective = 1000, prev rate = 1/4 => total allowance = 250 - // account share = 100 / 500 -> 1/5 => expected 50 - let current_epoch = 99; - let new_rate_activation_epoch = Some(100); - let prev = StakeHistoryEntry { - activating: 500, - effective: 1000, - ..Default::default() - }; - let result = - calculate_activation_allowance(current_epoch, 100, &prev, new_rate_activation_epoch); - assert_eq!(result, 50); + assert_eq!(allowance, 0); } - #[test] - fn activation_caps_at_account_portion_when_network_allowance_is_large() { - // total network allowance enormous relative to waiting stake, should cap to account_portion. - let current_epoch = 99; - let new_rate_activation_epoch = Some(100); // prev rate (1/4) - let prev = StakeHistoryEntry { - activating: 100, // cluster_portion - effective: 1_000_000, // large cluster effective - ..Default::default() - }; - let account_portion = 40; - let result = calculate_activation_allowance( + #[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, - &prev, - new_rate_activation_epoch, + cluster_portion, + cluster_effective, + rate_change_activation_epoch, ); - assert_eq!(result, account_portion); + assert_eq!(result, expected); } - #[test_case(0, 10, 100; "account portion is zero")] - #[test_case(5, 0, 100; "cluster portion is zero")] - #[test_case(5, 10, 0; "cluster effective is zero")] - fn cooldown_zero_cases_return_zero( + #[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, ) { - let prev = StakeHistoryEntry { - deactivating: cluster_portion, - effective: cluster_effective, - ..Default::default() - }; - assert_eq!( - calculate_deactivation_allowance(0, account_portion, &prev, Some(0)), - 0 + // 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] - fn cooldown_basic_proportional_curr_rate() { - // cluster_effective = 10_000, curr rate = 9/100 => total allowance = 900 - // account share = 200 / 1000 => expected 180 - let current_epoch = 5; - let new_rate_activation_epoch = Some(5); // current (epoch >= activation) - let prev = StakeHistoryEntry { - deactivating: 1000, - effective: 10_000, - ..Default::default() - }; - let result = - calculate_deactivation_allowance(current_epoch, 200, &prev, new_rate_activation_epoch); - assert_eq!(result, 180); - } - - #[test] - fn cooldown_overflow_scenario_still_rate_limits() { + #[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 deactivate everything at once. Asserting rate limiting is maintained. + // 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 prev = StakeHistoryEntry { - deactivating: supply_lamports, - effective: supply_lamports, - ..Default::default() - }; - let actual_result = calculate_deactivation_allowance( + let actual_result = kind.calculate_allowance( 100, account_portion, - &prev, + supply_lamports, + supply_lamports, None, // forces 25% rate ); @@ -307,7 +297,10 @@ mod test { // 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.div(denominator).min(account_portion as u128) as u64; + 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 @@ -316,44 +309,6 @@ mod test { assert!(actual_result <= ideal_allowance); } - #[test] - fn cooldown_caps_at_account_portion_when_network_allowance_is_large() { - let current_epoch = 0; - let new_rate_activation_epoch = None; // uses prev (1/4) - let prev = StakeHistoryEntry { - deactivating: 100, - effective: 1_000_000, - ..Default::default() - }; - let account_portion = 70; - let result = calculate_deactivation_allowance( - current_epoch, - account_portion, - &prev, - new_rate_activation_epoch, - ); - assert_eq!(result, account_portion); - } - - // === Symmetry & integer rounding === - - #[test] - fn activation_and_cooldown_are_symmetric_given_same_inputs() { - // With equal cluster_portions and same epoch/rate, the math should match. - let epoch = 42; - let new_rate_activation_epoch = Some(1_000); // prev rate for both calls - let prev = StakeHistoryEntry { - activating: 1_000, - deactivating: 1_000, - effective: 5_000, - }; - let account = 333; - let act = calculate_activation_allowance(epoch, account, &prev, new_rate_activation_epoch); - let cool = - calculate_deactivation_allowance(epoch, account, &prev, new_rate_activation_epoch); - assert_eq!(act, cool); - } - #[test] fn integer_division_truncation_matches_expected() { // Float math would yield 90.009, integer math must truncate to 90 @@ -361,26 +316,26 @@ mod test { let cluster_portion = 1000; let cluster_effective = 10001; let epoch = 20; - let new_rate_activation_epoch = Some(10); // current 9/100 + let rate_change_activation_epoch = Some(10); // current 9/100 - let result = rate_limited_stake_change( + let result = calculate_stake_change_allowance( epoch, account_portion, cluster_portion, cluster_effective, - new_rate_activation_epoch, + rate_change_activation_epoch, ); assert_eq!(result, 90); } - // === Property tests: compare the integer refactor vs legacy `f64` === + // === Legacy parity: compare the integer refactor vs legacy `f64` === #[allow(deprecated)] fn legacy_warmup_cooldown_rate( current_epoch: Epoch, - new_rate_activation_epoch: Option, + rate_change_activation_epoch: Option, ) -> f64 { - if current_epoch < new_rate_activation_epoch.unwrap_or(u64::MAX) { + if current_epoch < rate_change_activation_epoch.unwrap_or(u64::MAX) { DEFAULT_WARMUP_COOLDOWN_RATE } else { NEW_WARMUP_COOLDOWN_RATE @@ -393,13 +348,13 @@ mod test { cluster_portion: u64, cluster_effective: u64, current_epoch: Epoch, - new_rate_activation_epoch: Option, + 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, new_rate_activation_epoch); + 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 } @@ -413,14 +368,14 @@ mod test { cluster_portion in 0u64..=u64::MAX, cluster_effective in 0u64..=u64::MAX, current_epoch in 0u64..=2000, - new_rate_activation_epoch_option in prop::option::of(0u64..=2000), + rate_change_activation_epoch_option in prop::option::of(0u64..=2000), ) { - let integer_math_result = rate_limited_stake_change( + let integer_math_result = calculate_stake_change_allowance( current_epoch, account_portion, cluster_portion, cluster_effective, - new_rate_activation_epoch_option, + rate_change_activation_epoch_option, ); let float_math_result = calculate_stake_delta_f64_legacy( @@ -428,11 +383,11 @@ mod test { cluster_portion, cluster_effective, current_epoch, - new_rate_activation_epoch_option, + rate_change_activation_epoch_option, ).min(account_portion); let rate_bps = - warmup_cooldown_rate_bps(current_epoch, new_rate_activation_epoch_option); + 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) @@ -444,11 +399,16 @@ mod test { prop_assert_eq!(integer_math_result, 0); prop_assert_eq!(float_math_result, 0); } else if would_overflow { - // In the overflow path, the numerator is `u128::MAX` and is divided then clamped to - // `account_portion`. This math often results in less than `account_portion`, but - // never should exceed it. It may be equal in the case the denominator is small and - // post-division result gets clamped. - prop_assert!(integer_math_result <= account_portion); + // 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); From ecae8cc6ef4e85e595dfce6f4e21028aa2040474 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Tue, 21 Apr 2026 18:37:16 -0700 Subject: [PATCH 3/3] Update deprecation comments --- interface/src/state.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/interface/src/state.rs b/interface/src/state.rs index f961d74c..af97c3f8 100644 --- a/interface/src/state.rs +++ b/interface/src/state.rs @@ -29,15 +29,18 @@ pub type StakeActivationStatus = StakeHistoryEntry; // Means that no more than RATE of current effective stake may be added or subtracted per // epoch. #[deprecated( - since = "3.1.0", - note = "Use ORIGINAL_WARMUP_COOLDOWN_RATE_BPS instead" + 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.1.0", note = "Use TOWER_WARMUP_COOLDOWN_RATE_BPS instead")] +#[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.1.0", note = "Use warmup_cooldown_rate_bps() instead")] +#[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 @@ -534,7 +537,7 @@ impl Delegation { /// 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.1.0", note = "Use stake_v2() instead")] + #[deprecated(since = "3.2.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch, @@ -548,7 +551,7 @@ impl Delegation { /// 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.1.0", + since = "3.2.0", note = "Use stake_activating_and_deactivating_v2() instead" )] pub fn stake_activating_and_deactivating( @@ -638,7 +641,7 @@ impl Delegation { } // returned tuple is (effective, activating) stake - #[deprecated(since = "3.1.0", note = "Use stake_and_activating_v2() instead")] + #[deprecated(since = "3.2.0", note = "Use stake_and_activating_v2() instead")] fn stake_and_activating( &self, target_epoch: Epoch, @@ -940,7 +943,7 @@ pub struct Stake { } impl Stake { - #[deprecated(since = "3.1.0", note = "Use stake_v2() instead")] + #[deprecated(since = "3.2.0", note = "Use stake_v2() instead")] pub fn stake( &self, epoch: Epoch,