From b28d10d46fe2274f79892c2eaee56bc73bf3c092 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2020 06:02:34 +0000 Subject: [PATCH] Add bank timestamp bounding (bp #13120) (#13331) * Add bounding feature (cherry picked from commit 96b8aa8bd1c90f391cc30052bf23b97edc02a673) * Repurpose unused as Clock::epoch_start_timestamp; add gated update (cherry picked from commit 0049ab69fb111c533836e875a0ea231aadd3f2f8) * Add bounded timestamp-estimation method (cherry picked from commit 80db6c0980e28ee60f7f36f87bcbb7b8a8515642) * Use bounded timestamp-correction when feature enabled (cherry picked from commit 90778615f6624c4ef320a0af697dea00872a1242) * Prevent block times from ever going backward (cherry picked from commit eb2560e78278fc4585e73eff468b9b45be2182cd) * Sample votes from ancestors back to root (cherry picked from commit 4260b3b41690368eb4cf4923fc3930bf43a4946e) * Add Clock sysvar details, update struct docs (cherry picked from commit 3a1e125ce38cc58b3593bb17e1789318af087824) * Add design proposal and update validator-timestamp-oracle (cherry picked from commit a3912bc084f5c1436df1639e58343e54dfccc4c2) * Adapt to feature::create_account Co-authored-by: Tyera Eulberg Co-authored-by: Michael Vines --- docs/sidebars.js | 1 + docs/src/apps/sysvars.md | 22 + .../bank-timestamp-correction.md | 79 ++++ .../validator-timestamp-oracle.md | 18 +- ledger/src/blockstore.rs | 16 +- runtime/src/bank.rs | 262 +++++++++-- sdk/program/src/clock.rs | 9 +- sdk/src/feature_set.rs | 5 + sdk/src/stake_weighted_timestamp.rs | 435 +++++++++++++++++- 9 files changed, 795 insertions(+), 52 deletions(-) create mode 100644 docs/src/implemented-proposals/bank-timestamp-correction.md diff --git a/docs/sidebars.js b/docs/sidebars.js index 2f6a922e5f..ebdb107a39 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -173,6 +173,7 @@ module.exports = { ], }, "implemented-proposals/abi-management", + "implemented-proposals/bank-timestamp-correction", "implemented-proposals/commitment", "implemented-proposals/cross-program-invocation", "implemented-proposals/durable-tx-nonces", diff --git a/docs/src/apps/sysvars.md b/docs/src/apps/sysvars.md index 42a59de7a8..0af3cfa4a7 100644 --- a/docs/src/apps/sysvars.md +++ b/docs/src/apps/sysvars.md @@ -20,6 +20,28 @@ epoch, and estimated wall-clock Unix timestamp. It is updated every slot. - Address: `SysvarC1ock11111111111111111111111111111111` - Layout: [Clock](https://docs.rs/solana-program/VERSION_FOR_DOCS_RS/solana_program/clock/struct.Clock.html) +- Fields: + - `slot`: the current slot + - `epoch_start_timestamp`: the Unix timestamp of the first slot in this epoch. In the first slot of an epoch, this timestamp is identical to the `unix_timestamp` (below). + - `epoch`: the current epoch + - `leader_schedule_epoch`: the most recent epoch for which the leader schedule has already been generated + - `unix_timestamp`: the Unix timestamp of this slot. + + Each slot has an estimated duration based on Proof of History. But in reality, + slots may elapse faster and slower than this estimate. As a result, the Unix + timestamp of a slot is generated based on oracle input from voting validators. + This timestamp is calculated as the stake-weighted median of timestamp + estimates provided by votes, bounded by the expected time elapsed since the + start of the epoch. + + More explicitly: for each slot, the most recent vote timestamp provided by + each validator is used to generate a timestamp estimate for the current slot + (the elapsed slots since the vote timestamp are assumed to be + Bank::ns_per_slot). Each timestamp estimate is associated with the stake + delegated to that vote account to create a distribution of timestamps by + stake. The median timestamp is used as the `unix_timestamp`, unless the + elapsed time since the `epoch_start_timestamp` has deviated from the expected + elapsed time by more than 25%. ## EpochSchedule diff --git a/docs/src/implemented-proposals/bank-timestamp-correction.md b/docs/src/implemented-proposals/bank-timestamp-correction.md new file mode 100644 index 0000000000..4b8a80f6cc --- /dev/null +++ b/docs/src/implemented-proposals/bank-timestamp-correction.md @@ -0,0 +1,79 @@ +--- +title: Bank Timestamp Correction +--- + +Each Bank has a timestamp that is stashed in the Clock sysvar and used to assess +time-based stake account lockups. However, since genesis, this value has been +based on a theoretical slots-per-second instead of reality, so it's quite +inaccurate. This poses a problem for lockups, since the accounts will not +register as lockup-free on (or anytime near) the date the lockup is set to +expire. + +Block times are already being estimated to cache in Blockstore and long-term +storage using a [validator timestamp oracle](validator-timestamp-oracle.md); +this data provides an opportunity to align the bank timestamp more closely with +real-world time. + +The general outline of the proposed implementation is as follows: + +- Correct each Bank timestamp using the validator-provided timestamp. +- Update the validator-provided timestamp calculation to use a stake-weighted + median, rather than a stake-weighted mean. +- Bound the timestamp correction so that it cannot deviate too far from the + expected theoretical estimate + +## Timestamp Correction + +On every new Bank, the runtime calculates a realistic timestamp estimate using +validator timestamp-oracle data. The Bank timestamp is corrected to this value +if it is greater than or equal to the previous Bank's timestamp. That is, time +should not ever go backward, so that locked up accounts may be released by the +correction, but once released, accounts can never be relocked by a time +correction. + +### Calculating Stake-Weighted Median Timestamp + +In order to calculate the estimated timestamp for a particular Bank, the runtime +first needs to get the most recent vote timestamps from the active validator +set. The `Bank::vote_accounts()` method provides the vote accounts state, and +these can be filtered to all accounts whose most recent timestamp is for an +ancestor slot back to the current root. This should guarantee 2/3+ of the +current cluster stake is represented, since by definition, roots must be +confirmed by 2/3+ stake. + +From each vote timestamp, an estimate for the current Bank is calculated using +the epoch's target ns_per_slot for any delta between the Bank slot and the +timestamp slot. Each timestamp estimate is is associated with the stake +delegated to that vote account, and all the timestamps are collected to create a +stake-weighted timestamp distribution. + +From this set, the stake-weighted median timestamp -- that is, the timestamp at +which 50% of the stake estimates a greater-or-equal timestamp and 50% of the +stake estimates a lesser-or-equal timestamp -- is selected as the potential +corrected timestamp. + +This stake-weighted median timestamp is preferred over the stake-weighted mean +because the multiplication of stake by proposed timestamp in the mean +calculation allows a node with very small stake to still have a large effect on +the resulting timestamp by proposing a timestamp that is very large or very +small. For example, using the previous `calculate_stake_weighted_timestamp()` +method, a node with 0.00003% of the stake proposing a timestamp of `i64::MAX` +can shift the timestamp forward 97k years! + +### Bounding Timestamps + +In addition to preventing time moving backward, we can prevent malicious +activity by bounding the corrected timestamp to an acceptable level of deviation +from the theoretical expected time. + +This proposal suggests that each timestamp be allowed to deviate up to 25% from +the expected time since the start of the epoch. + +In order to calculate the timestamp deviation, each Bank needs to log the +`epoch_start_timestamp` in the Clock sysvar. This value is set to the +`Clock::unix_timestamp` on the first slot of each epoch. + +Then, the runtime compares the expected elapsed time since the start of the +epoch with the proposed elapsed time based on the corrected timestamp. If the +corrected elaped time is within +/- 25% of expected, the corrected timestamp is +accepted. Otherwise, it is bounded to the acceptable deviation. diff --git a/docs/src/implemented-proposals/validator-timestamp-oracle.md b/docs/src/implemented-proposals/validator-timestamp-oracle.md index a595e710a5..cc0bcef54a 100644 --- a/docs/src/implemented-proposals/validator-timestamp-oracle.md +++ b/docs/src/implemented-proposals/validator-timestamp-oracle.md @@ -48,19 +48,11 @@ Vote vector (`Vote::slots.iter().max()`). It is signed by the validator's identity keypair as a usual Vote. In order to enable this reporting, the Vote struct needs to be extended to include a timestamp field, `timestamp: Option`, which will be set to `None` in most Votes. -This proposal suggests that Vote instructions with `Some(timestamp)` be issued -every 30min, which should be short enough to prevent block times drifting very -much, without adding too much transaction overhead to the cluster. Validators -can convert this time to a slot interval using the `slots_per_year` value that -is stored in each bank. - -```text -let seconds_in_30min = 1800; -let timestamp_interval = (slots_per_year / SECONDS_PER_YEAR) * seconds_in_30min; -``` - -Votes with `Some(timestamp)` should be triggered in `replay_stage::handle_votable_bank()` -when `bank.slot() % timestamp_interval == 0`. +As of https://github.com/solana-labs/solana/pull/10630, validators submit a +timestamp every vote. This enables implementation of a block time caching +service that allows nodes to calculate the estimated timestamp immediately after +the block is rooted, and cache that value in Blockstore. This provides +persistent data and quick queries, while still meeting requirement 1) above. ### Vote Accounts 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 a1e56fb8b3..503326268d 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, DEPRECATED_TIMESTAMP_SLOT_RANGE, + }, system_transaction, sysvar::{self}, timing::years_as_slots, @@ -741,7 +743,7 @@ impl Bank { } bank.update_stake_history(None); } - bank.update_clock(); + bank.update_clock(None); bank.update_rent(); bank.update_epoch_schedule(); bank.update_recent_blockhashes(); @@ -844,7 +846,7 @@ impl Bank { new.update_slot_hashes(); new.update_rewards(parent.epoch()); new.update_stake_history(Some(parent.epoch())); - new.update_clock(); + new.update_clock(Some(parent.epoch())); new.update_fees(); if !new.fix_recent_blockhashes_sysvar_delay() { new.update_recent_blockhashes(); @@ -1073,26 +1075,74 @@ impl Bank { .unwrap_or_default() } - fn update_clock(&self) { + fn update_clock(&self, parent_epoch: Option) { let mut unix_timestamp = self.unix_timestamp_from_genesis(); if self .feature_set .is_active(&feature_set::timestamp_correction::id()) { - if let Some(timestamp_estimate) = self.get_timestamp_estimate() { + let (estimate_type, epoch_start_timestamp) = + if let Some(timestamp_bounding_activation_slot) = self + .feature_set + .activated_slot(&feature_set::timestamp_bounding::id()) + { + // This check avoids a chicken-egg problem with epoch_start_timestamp, which is + // needed for timestamp bounding, but isn't yet corrected for the activation slot + let epoch_start_timestamp = if self.slot() > timestamp_bounding_activation_slot + { + let epoch = if let Some(epoch) = parent_epoch { + epoch + } else { + self.epoch() + }; + let first_slot_in_epoch = + self.epoch_schedule.get_first_slot_in_epoch(epoch); + Some((first_slot_in_epoch, self.clock().epoch_start_timestamp)) + } else { + None + }; + (EstimateType::Bounded, epoch_start_timestamp) + } else { + (EstimateType::Unbounded, None) + }; + if let Some(timestamp_estimate) = + self.get_timestamp_estimate(estimate_type, epoch_start_timestamp) + { if timestamp_estimate > unix_timestamp { datapoint_info!( "bank-timestamp-correction", ("from_genesis", unix_timestamp, i64), ("corrected", timestamp_estimate, i64), ); - unix_timestamp = timestamp_estimate + unix_timestamp = timestamp_estimate; + + let ancestor_timestamp = self.clock().unix_timestamp; + if self + .feature_set + .is_active(&feature_set::timestamp_bounding::id()) + && timestamp_estimate < ancestor_timestamp + { + unix_timestamp = ancestor_timestamp; + } } } } + let epoch_start_timestamp = if self + .feature_set + .is_active(&feature_set::timestamp_bounding::id()) + { + // On epoch boundaries, update epoch_start_timestamp + if parent_epoch.is_some() && parent_epoch.unwrap() != self.epoch() { + unix_timestamp + } else { + self.clock().epoch_start_timestamp + } + } else { + Self::get_unused_from_slot(self.slot, self.unused) as i64 + }; let clock = sysvar::clock::Clock { slot: self.slot, - unused: Self::get_unused_from_slot(self.slot, self.unused), + epoch_start_timestamp, epoch: self.epoch_schedule.get_epoch(self.slot), leader_schedule_epoch: self.epoch_schedule.get_leader_schedule_epoch(self.slot), unix_timestamp, @@ -1427,7 +1477,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() @@ -1435,7 +1489,13 @@ impl Bank { .filter_map(|(pubkey, (_, account))| { VoteState::from(&account).and_then(|state| { let timestamp_slot = state.last_timestamp.slot; - if self.slot().checked_sub(timestamp_slot)? <= TIMESTAMP_SLOT_RANGE as u64 { + if (self + .feature_set + .is_active(&feature_set::timestamp_bounding::id()) + && self.ancestors.contains_key(×tamp_slot)) + || self.slot().checked_sub(timestamp_slot)? + <= DEPRECATED_TIMESTAMP_SLOT_RANGE as u64 + { Some(( pubkey, (state.last_timestamp.slot, state.last_timestamp.timestamp), @@ -1454,6 +1514,8 @@ impl Bank { stakes, self.slot(), slot_duration, + estimate_type, + epoch_start_timestamp, ); get_timestamp_estimate_time.stop(); datapoint_info!( @@ -9638,7 +9700,7 @@ mod tests { let validator_vote_keypairs1 = ValidatorVoteKeypairs::new_rand(); let validator_keypairs = vec![&validator_vote_keypairs0, &validator_vote_keypairs1]; let GenesisConfigInfo { - genesis_config, + mut genesis_config, mint_keypair: _, voting_keypair: _, } = create_genesis_config_with_vote_accounts( @@ -9646,8 +9708,15 @@ mod tests { &validator_keypairs, vec![10_000; 2], ); + genesis_config + .accounts + .remove(&feature_set::timestamp_bounding::id()) + .unwrap(); 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( @@ -9668,7 +9737,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) ); @@ -9677,14 +9746,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] @@ -9699,6 +9771,10 @@ mod tests { .accounts .remove(&feature_set::timestamp_correction::id()) .unwrap(); + genesis_config + .accounts + .remove(&feature_set::timestamp_bounding::id()) + .unwrap(); let bank = Bank::new(&genesis_config); let recent_timestamp: UnixTimestamp = bank.unix_timestamp_from_genesis(); @@ -9714,10 +9790,10 @@ mod tests { // Bank::new_from_parent should not adjust timestamp before feature activation let mut bank = new_from_parent(&Arc::new(bank)); - let clock = - from_account::(&bank.get_account(&sysvar::clock::id()).unwrap()) - .unwrap(); - assert_eq!(clock.unix_timestamp, bank.unix_timestamp_from_genesis()); + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() + ); // Request `timestamp_correction` activation bank.store_account( @@ -9733,15 +9809,131 @@ mod tests { // Now Bank::new_from_parent should adjust timestamp let bank = Arc::new(new_from_parent(&Arc::new(bank))); - let clock = - from_account::(&bank.get_account(&sysvar::clock::id()).unwrap()) - .unwrap(); assert_eq!( - clock.unix_timestamp, + bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() + additional_secs ); } + #[test] + fn test_timestamp_bounding_feature() { + let leader_pubkey = solana_sdk::pubkey::new_rand(); + let GenesisConfigInfo { + mut genesis_config, + voting_keypair, + .. + } = create_genesis_config_with_leader(5, &leader_pubkey, 3); + let slots_in_epoch = 32; + genesis_config + .accounts + .remove(&feature_set::timestamp_bounding::id()) + .unwrap(); + genesis_config.epoch_schedule = EpochSchedule::new(slots_in_epoch); + let bank = Bank::new(&genesis_config); + + let recent_timestamp: UnixTimestamp = bank.unix_timestamp_from_genesis(); + let additional_secs = 1; + update_vote_account_timestamp( + BlockTimestamp { + slot: bank.slot(), + timestamp: recent_timestamp + additional_secs, + }, + &bank, + &voting_keypair.pubkey(), + ); + + // Bank::new_from_parent should allow unbounded timestamp before activation + let mut bank = new_from_parent(&Arc::new(bank)); + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() + additional_secs + ); + + // Bank::new_from_parent should not allow epoch_start_timestamp to be set before activation + bank.update_clock(Some(0)); + assert_eq!( + bank.clock().epoch_start_timestamp, + Bank::get_unused_from_slot(bank.slot(), bank.unused) as i64 + ); + + // Request `timestamp_bounding` activation + let feature = Feature { activated_at: None }; + bank.store_account( + &feature_set::timestamp_bounding::id(), + &feature::create_account(&feature, 42), + ); + for _ in 0..30 { + bank = new_from_parent(&Arc::new(bank)); + } + + // Refresh vote timestamp + let recent_timestamp: UnixTimestamp = bank.unix_timestamp_from_genesis(); + let additional_secs = 1; + update_vote_account_timestamp( + BlockTimestamp { + slot: bank.slot(), + timestamp: recent_timestamp + additional_secs, + }, + &bank, + &voting_keypair.pubkey(), + ); + + // Advance to epoch boundary to activate + bank = new_from_parent(&Arc::new(bank)); + + // Bank::new_from_parent is bounding, but should not use epoch_start_timestamp in activation slot + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() + additional_secs + ); + + assert_eq!( + bank.clock().epoch_start_timestamp, + bank.unix_timestamp_from_genesis() + additional_secs + ); + + // Past activation slot, bounding should use epoch_start_timestamp in activation slot + bank = new_from_parent(&Arc::new(bank)); + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() + ); + + for _ in 0..30 { + bank = new_from_parent(&Arc::new(bank)); + } + + // Refresh vote timestamp + let recent_timestamp: UnixTimestamp = bank.unix_timestamp_from_genesis(); + let additional_secs = 20; + update_vote_account_timestamp( + BlockTimestamp { + slot: bank.slot(), + timestamp: recent_timestamp + additional_secs, + }, + &bank, + &voting_keypair.pubkey(), + ); + + // Advance to epoch boundary + bank = new_from_parent(&Arc::new(bank)); + + // Past activation slot, bounding should use previous epoch_start_timestamp on epoch boundary slots + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() // Plus estimated offset + 25% + + ((slots_in_epoch as u32 * Duration::from_nanos(bank.ns_per_slot as u64)) + .as_secs() + * 25 + / 100) as i64, + ); + + assert_eq!( + bank.clock().epoch_start_timestamp, + bank.clock().unix_timestamp + ); + } + #[test] fn test_update_clock_timestamp() { let leader_pubkey = solana_sdk::pubkey::new_rand(); @@ -9750,13 +9942,13 @@ mod tests { voting_keypair, .. } = create_genesis_config_with_leader(5, &leader_pubkey, 3); - let bank = Bank::new(&genesis_config); + let mut bank = Bank::new(&genesis_config); assert_eq!( bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() ); - bank.update_clock(); + bank.update_clock(None); assert_eq!( bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() @@ -9770,7 +9962,7 @@ mod tests { &bank, &voting_keypair.pubkey(), ); - bank.update_clock(); + bank.update_clock(None); assert_eq!( bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() @@ -9784,7 +9976,7 @@ mod tests { &bank, &voting_keypair.pubkey(), ); - bank.update_clock(); + bank.update_clock(None); assert_eq!( bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() @@ -9798,11 +9990,27 @@ mod tests { &bank, &voting_keypair.pubkey(), ); - bank.update_clock(); + bank.update_clock(None); assert_eq!( bank.clock().unix_timestamp, bank.unix_timestamp_from_genesis() + 1 ); + + // Timestamp cannot go backward from ancestor Bank to child + bank = new_from_parent(&Arc::new(bank)); + update_vote_account_timestamp( + BlockTimestamp { + slot: bank.slot(), + timestamp: bank.unix_timestamp_from_genesis() - 1, + }, + &bank, + &voting_keypair.pubkey(), + ); + bank.update_clock(None); + assert_eq!( + bank.clock().unix_timestamp, + bank.unix_timestamp_from_genesis() + ); } fn setup_bank_with_removable_zero_lamport_account() -> Arc { diff --git a/sdk/program/src/clock.rs b/sdk/program/src/clock.rs index f576a9bb61..1a55a81ff5 100644 --- a/sdk/program/src/clock.rs +++ b/sdk/program/src/clock.rs @@ -79,14 +79,15 @@ pub type UnixTimestamp = i64; pub struct Clock { /// the current network/bank Slot pub slot: Slot, - /// unused - pub unused: u64, + /// the timestamp of the first Slot in this Epoch + pub epoch_start_timestamp: UnixTimestamp, /// the bank Epoch pub epoch: Epoch, /// the future Epoch for which the leader schedule has /// most recently been calculated pub leader_schedule_epoch: Epoch, - /// computed from genesis creation time and network time - /// in slots, drifts! + /// originally computed from genesis creation time and network time + /// in slots (drifty); corrected using validator timestamp oracle as of + /// timestamp_correction and timestamp_bounding features pub unix_timestamp: UnixTimestamp, } diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index 74265a56f7..8db55da8e7 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -78,6 +78,10 @@ pub mod pull_request_ping_pong_check { solana_sdk::declare_id!("5RzEHTnf6D7JPZCvwEzjM19kzBsyjSU3HoMfXaQmVgnZ"); } +pub mod timestamp_bounding { + solana_sdk::declare_id!("8FyEA6ABYiMxX7Az6AopQN3mavLD8Rz3N4bvKnbbBFFq"); +} + lazy_static! { /// Map of feature identifiers to user-visible description pub static ref FEATURE_NAMES: HashMap = [ @@ -99,6 +103,7 @@ lazy_static! { (sol_log_compute_units_syscall::id(), "sol_log_compute_units syscall (#13243)"), (pubkey_log_syscall_enabled::id(), "pubkey log syscall"), (pull_request_ping_pong_check::id(), "ping-pong packet check #12794"), + (timestamp_bounding::id(), "add timestamp-correction bounding #13120"), /*************** ADD NEW FEATURES HERE ***************/ ] .iter() diff --git a/sdk/src/stake_weighted_timestamp.rs b/sdk/src/stake_weighted_timestamp.rs index 560903d073..2ea172cd87 100644 --- a/sdk/src/stake_weighted_timestamp.rs +++ b/sdk/src/stake_weighted_timestamp.rs @@ -5,15 +5,50 @@ 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; +pub const TIMESTAMP_SLOT_RANGE: usize = 32; +pub const DEPRECATED_TIMESTAMP_SLOT_RANGE: usize = 16; // Deprecated. Remove in the Solana v1.6.0 timeframe +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 +71,67 @@ 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 max_allowable_drift = poh_estimate_offset * MAX_ALLOWABLE_DRIFT_PERCENTAGE / 100; + if estimate_offset > poh_estimate_offset + && estimate_offset - poh_estimate_offset > max_allowable_drift + { + // estimate offset since the start of the epoch is higher than + // `MAX_ALLOWABLE_DRIFT_PERCENTAGE` + estimate = epoch_start_timestamp + + poh_estimate_offset.as_secs() as i64 + + max_allowable_drift.as_secs() as i64; + } else if estimate_offset < poh_estimate_offset + && poh_estimate_offset - estimate_offset > max_allowable_drift + { + // estimate offset since the start of the epoch is lower than + // `MAX_ALLOWABLE_DRIFT_PERCENTAGE` + estimate = epoch_start_timestamp + poh_estimate_offset.as_secs() as i64 + - max_allowable_drift.as_secs() as i64; + } + } + Some(estimate) +} + #[cfg(test)] pub mod tests { use super::*; @@ -95,7 +191,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 +234,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 +243,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 + acceptable_delta); + + // 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 - acceptable_delta); + + // 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); + } }