diff --git a/ledger/src/blockstore.rs b/ledger/src/blockstore.rs index 49878bcf54..b01c373fec 100644 --- a/ledger/src/blockstore.rs +++ b/ledger/src/blockstore.rs @@ -33,7 +33,9 @@ use solana_sdk::{ program_utils::limited_deserialize, pubkey::Pubkey, signature::{Keypair, Signature, Signer}, - stake_weighted_timestamp::{calculate_stake_weighted_timestamp, TIMESTAMP_SLOT_RANGE}, + stake_weighted_timestamp::{ + calculate_stake_weighted_timestamp, EstimateType, TIMESTAMP_SLOT_RANGE, + }, timing::timestamp, transaction::Transaction, }; @@ -1638,9 +1640,15 @@ impl Blockstore { } let mut calculate_timestamp = Measure::start("calculate_timestamp"); - let stake_weighted_timestamp = - calculate_stake_weighted_timestamp(&unique_timestamps, stakes, slot, slot_duration) - .ok_or(BlockstoreError::EmptyEpochStakes)?; + let stake_weighted_timestamp = calculate_stake_weighted_timestamp( + &unique_timestamps, + stakes, + slot, + slot_duration, + EstimateType::Unbounded, + None, + ) + .ok_or(BlockstoreError::EmptyEpochStakes)?; calculate_timestamp.stop(); datapoint_info!( "blockstore-get-block-time", diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index 471686cc9a..fe69435a17 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -59,7 +59,9 @@ use solana_sdk::{ signature::{Keypair, Signature}, slot_hashes::SlotHashes, slot_history::SlotHistory, - stake_weighted_timestamp::{calculate_stake_weighted_timestamp, TIMESTAMP_SLOT_RANGE}, + stake_weighted_timestamp::{ + calculate_stake_weighted_timestamp, EstimateType, TIMESTAMP_SLOT_RANGE, + }, system_transaction, sysvar::{self}, timing::years_as_slots, @@ -1079,7 +1081,9 @@ impl Bank { .feature_set .is_active(&feature_set::timestamp_correction::id()) { - if let Some(timestamp_estimate) = self.get_timestamp_estimate() { + if let Some(timestamp_estimate) = + self.get_timestamp_estimate(EstimateType::Unbounded, None) + { if timestamp_estimate > unix_timestamp { datapoint_info!( "bank-timestamp-correction", @@ -1440,7 +1444,11 @@ impl Bank { self.update_recent_blockhashes_locked(&blockhash_queue); } - fn get_timestamp_estimate(&self) -> Option { + fn get_timestamp_estimate( + &self, + estimate_type: EstimateType, + epoch_start_timestamp: Option<(Slot, UnixTimestamp)>, + ) -> Option { let mut get_timestamp_estimate_time = Measure::start("get_timestamp_estimate"); let recent_timestamps: HashMap = self .vote_accounts() @@ -1467,6 +1475,8 @@ impl Bank { stakes, self.slot(), slot_duration, + estimate_type, + epoch_start_timestamp, ); get_timestamp_estimate_time.stop(); datapoint_info!( @@ -9661,7 +9671,10 @@ mod tests { vec![10_000; 2], ); let mut bank = Bank::new(&genesis_config); - assert_eq!(bank.get_timestamp_estimate(), Some(0)); + assert_eq!( + bank.get_timestamp_estimate(EstimateType::Unbounded, None), + Some(0) + ); let recent_timestamp: UnixTimestamp = bank.unix_timestamp_from_genesis(); update_vote_account_timestamp( @@ -9682,7 +9695,7 @@ mod tests { &validator_vote_keypairs1.vote_keypair.pubkey(), ); assert_eq!( - bank.get_timestamp_estimate(), + bank.get_timestamp_estimate(EstimateType::Unbounded, None), Some(recent_timestamp + additional_secs / 2) ); @@ -9691,14 +9704,17 @@ mod tests { } let adjustment = (bank.ns_per_slot as u64 * bank.slot()) / 1_000_000_000; assert_eq!( - bank.get_timestamp_estimate(), + bank.get_timestamp_estimate(EstimateType::Unbounded, None), Some(recent_timestamp + adjustment as i64 + additional_secs / 2) ); for _ in 0..7 { bank = new_from_parent(&Arc::new(bank)); } - assert_eq!(bank.get_timestamp_estimate(), None); + assert_eq!( + bank.get_timestamp_estimate(EstimateType::Unbounded, None), + None + ); } #[test] diff --git a/sdk/src/stake_weighted_timestamp.rs b/sdk/src/stake_weighted_timestamp.rs index 560903d073..c7417ef2b4 100644 --- a/sdk/src/stake_weighted_timestamp.rs +++ b/sdk/src/stake_weighted_timestamp.rs @@ -5,15 +5,49 @@ use solana_sdk::{ clock::{Slot, UnixTimestamp}, pubkey::Pubkey, }; -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::{BTreeMap, HashMap}, + time::Duration, +}; pub const TIMESTAMP_SLOT_RANGE: usize = 16; +const MAX_ALLOWABLE_DRIFT_PERCENTAGE: u32 = 25; + +pub enum EstimateType { + Bounded, + Unbounded, // Deprecated. Remove in the Solana v1.6.0 timeframe +} pub fn calculate_stake_weighted_timestamp( unique_timestamps: &HashMap, stakes: &HashMap, slot: Slot, slot_duration: Duration, + estimate_type: EstimateType, + epoch_start_timestamp: Option<(Slot, UnixTimestamp)>, +) -> Option { + match estimate_type { + EstimateType::Bounded => calculate_bounded_stake_weighted_timestamp( + unique_timestamps, + stakes, + slot, + slot_duration, + epoch_start_timestamp, + ), + EstimateType::Unbounded => calculate_unbounded_stake_weighted_timestamp( + unique_timestamps, + stakes, + slot, + slot_duration, + ), + } +} + +fn calculate_unbounded_stake_weighted_timestamp( + unique_timestamps: &HashMap, + stakes: &HashMap, + slot: Slot, + slot_duration: Duration, ) -> Option { let (stake_weighted_timestamps_sum, total_stake) = unique_timestamps .iter() @@ -36,6 +70,58 @@ pub fn calculate_stake_weighted_timestamp( } } +fn calculate_bounded_stake_weighted_timestamp( + unique_timestamps: &HashMap, + stakes: &HashMap, + slot: Slot, + slot_duration: Duration, + epoch_start_timestamp: Option<(Slot, UnixTimestamp)>, +) -> Option { + let mut stake_per_timestamp: BTreeMap = BTreeMap::new(); + let mut total_stake = 0; + for (vote_pubkey, (timestamp_slot, timestamp)) in unique_timestamps.iter() { + let offset = slot.saturating_sub(*timestamp_slot) as u32 * slot_duration; + let estimate = timestamp + offset.as_secs() as i64; + let stake = stakes + .get(&vote_pubkey) + .map(|(stake, _account)| stake) + .unwrap_or(&0); + stake_per_timestamp + .entry(estimate) + .and_modify(|stake_sum| *stake_sum += *stake as u128) + .or_insert(*stake as u128); + total_stake += *stake as u128; + } + if total_stake == 0 { + return None; + } + let mut stake_accumulator = 0; + let mut estimate = 0; + // Populate `estimate` with stake-weighted median timestamp + for (timestamp, stake) in stake_per_timestamp.into_iter() { + stake_accumulator += stake; + if stake_accumulator > total_stake / 2 { + estimate = timestamp; + break; + } + } + // Bound estimate by `MAX_ALLOWABLE_DRIFT_PERCENTAGE` since the start of the epoch + if let Some((epoch_start_slot, epoch_start_timestamp)) = epoch_start_timestamp { + let poh_estimate_offset = slot.saturating_sub(epoch_start_slot) as u32 * slot_duration; + let estimate_offset = + Duration::from_secs(estimate.saturating_sub(epoch_start_timestamp) as u64); + let delta = if estimate_offset > poh_estimate_offset { + estimate_offset - poh_estimate_offset + } else { + poh_estimate_offset - estimate_offset + }; + if delta > poh_estimate_offset * MAX_ALLOWABLE_DRIFT_PERCENTAGE / 100 { + estimate = epoch_start_timestamp + poh_estimate_offset.as_secs() as i64; + } + } + Some(estimate) +} + #[cfg(test)] pub mod tests { use super::*; @@ -95,7 +181,7 @@ pub mod tests { .cloned() .collect(); assert_eq!( - calculate_stake_weighted_timestamp( + calculate_unbounded_stake_weighted_timestamp( &unique_timestamps, &stakes, slot as Slot, @@ -138,7 +224,7 @@ pub mod tests { .cloned() .collect(); assert_eq!( - calculate_stake_weighted_timestamp( + calculate_unbounded_stake_weighted_timestamp( &unique_timestamps, &stakes, slot as Slot, @@ -147,4 +233,335 @@ pub mod tests { Some(recent_timestamp + expected_offset as i64) ); } + + #[test] + fn test_calculate_bounded_stake_weighted_timestamp_uses_median() { + let recent_timestamp: UnixTimestamp = 1_578_909_061; + let slot = 5; + let slot_duration = Duration::from_millis(400); + let pubkey0 = solana_sdk::pubkey::new_rand(); + let pubkey1 = solana_sdk::pubkey::new_rand(); + let pubkey2 = solana_sdk::pubkey::new_rand(); + let pubkey3 = solana_sdk::pubkey::new_rand(); + let pubkey4 = solana_sdk::pubkey::new_rand(); + + // Test low-staked outlier(s) + let stakes: HashMap = [ + ( + pubkey0, + (sol_to_lamports(1.0), Account::new(1, 0, &Pubkey::default())), + ), + ( + pubkey1, + (sol_to_lamports(1.0), Account::new(1, 0, &Pubkey::default())), + ), + ( + pubkey2, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey3, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey4, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ] + .iter() + .cloned() + .collect(); + + let unique_timestamps: HashMap = [ + (pubkey0, (5, 0)), + (pubkey1, (5, recent_timestamp)), + (pubkey2, (5, recent_timestamp)), + (pubkey3, (5, recent_timestamp)), + (pubkey4, (5, recent_timestamp)), + ] + .iter() + .cloned() + .collect(); + + let unbounded = calculate_unbounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + ) + .unwrap(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + None, + ) + .unwrap(); + assert_eq!(bounded - unbounded, 527); // timestamp w/ 0.00003% of the stake can shift the timestamp backward 8min + assert_eq!(bounded, recent_timestamp); // low-staked outlier cannot affect bounded timestamp + + let unique_timestamps: HashMap = [ + (pubkey0, (5, recent_timestamp)), + (pubkey1, (5, i64::MAX)), + (pubkey2, (5, recent_timestamp)), + (pubkey3, (5, recent_timestamp)), + (pubkey4, (5, recent_timestamp)), + ] + .iter() + .cloned() + .collect(); + + let unbounded = calculate_unbounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + ) + .unwrap(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + None, + ) + .unwrap(); + assert_eq!(unbounded - bounded, 3074455295455); // timestamp w/ 0.00003% of the stake can shift the timestamp forward 97k years! + assert_eq!(bounded, recent_timestamp); // low-staked outlier cannot affect bounded timestamp + + let unique_timestamps: HashMap = [ + (pubkey0, (5, 0)), + (pubkey1, (5, i64::MAX)), + (pubkey2, (5, recent_timestamp)), + (pubkey3, (5, recent_timestamp)), + (pubkey4, (5, recent_timestamp)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + None, + ) + .unwrap(); + assert_eq!(bounded, recent_timestamp); // multiple low-staked outliers cannot affect bounded timestamp if they don't shift the median + + // Test higher-staked outlier(s) + let stakes: HashMap = [ + ( + pubkey0, + ( + sol_to_lamports(1_000_000.0), // 1/3 stake + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey1, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey2, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ] + .iter() + .cloned() + .collect(); + + let unique_timestamps: HashMap = [ + (pubkey0, (5, 0)), + (pubkey1, (5, i64::MAX)), + (pubkey2, (5, recent_timestamp)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + None, + ) + .unwrap(); + assert_eq!(bounded, recent_timestamp); // outlier(s) cannot affect bounded timestamp if they don't shift the median + + let stakes: HashMap = [ + ( + pubkey0, + ( + sol_to_lamports(1_000_001.0), // 1/3 stake + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey1, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ] + .iter() + .cloned() + .collect(); + + let unique_timestamps: HashMap = + [(pubkey0, (5, 0)), (pubkey1, (5, recent_timestamp))] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + None, + ) + .unwrap(); + assert_eq!(recent_timestamp - bounded, 1578909061); // outliers > 1/2 of available stake can affect timestamp + } + + #[test] + fn test_calculate_bounded_stake_weighted_timestamp_poh() { + let epoch_start_timestamp: UnixTimestamp = 1_578_909_061; + let slot = 20; + let slot_duration = Duration::from_millis(400); + let poh_offset = (slot * slot_duration).as_secs(); + let acceptable_delta = (MAX_ALLOWABLE_DRIFT_PERCENTAGE * poh_offset as u32 / 100) as i64; + let poh_estimate = epoch_start_timestamp + poh_offset as i64; + let pubkey0 = solana_sdk::pubkey::new_rand(); + let pubkey1 = solana_sdk::pubkey::new_rand(); + let pubkey2 = solana_sdk::pubkey::new_rand(); + + let stakes: HashMap = [ + ( + pubkey0, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey1, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ( + pubkey2, + ( + sol_to_lamports(1_000_000.0), + Account::new(1, 0, &Pubkey::default()), + ), + ), + ] + .iter() + .cloned() + .collect(); + + // Test when stake-weighted median is too high + let unique_timestamps: HashMap = [ + (pubkey0, (slot as u64, poh_estimate + acceptable_delta + 1)), + (pubkey1, (slot as u64, poh_estimate + acceptable_delta + 1)), + (pubkey2, (slot as u64, poh_estimate + acceptable_delta + 1)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + Some((0, epoch_start_timestamp)), + ) + .unwrap(); + assert_eq!(bounded, poh_estimate); + + // Test when stake-weighted median is too low + let unique_timestamps: HashMap = [ + (pubkey0, (slot as u64, poh_estimate - acceptable_delta - 1)), + (pubkey1, (slot as u64, poh_estimate - acceptable_delta - 1)), + (pubkey2, (slot as u64, poh_estimate - acceptable_delta - 1)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + Some((0, epoch_start_timestamp)), + ) + .unwrap(); + assert_eq!(bounded, poh_estimate); + + // Test stake-weighted median within bounds + let unique_timestamps: HashMap = [ + (pubkey0, (slot as u64, poh_estimate + acceptable_delta)), + (pubkey1, (slot as u64, poh_estimate + acceptable_delta)), + (pubkey2, (slot as u64, poh_estimate + acceptable_delta)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + Some((0, epoch_start_timestamp)), + ) + .unwrap(); + assert_eq!(bounded, poh_estimate + acceptable_delta); + + let unique_timestamps: HashMap = [ + (pubkey0, (slot as u64, poh_estimate - acceptable_delta)), + (pubkey1, (slot as u64, poh_estimate - acceptable_delta)), + (pubkey2, (slot as u64, poh_estimate - acceptable_delta)), + ] + .iter() + .cloned() + .collect(); + + let bounded = calculate_bounded_stake_weighted_timestamp( + &unique_timestamps, + &stakes, + slot as Slot, + slot_duration, + Some((0, epoch_start_timestamp)), + ) + .unwrap(); + assert_eq!(bounded, poh_estimate - acceptable_delta); + } }