diff --git a/programs/validator-history/idl/validator_history.json b/programs/validator-history/idl/validator_history.json index df4dc255..938fd22a 100644 --- a/programs/validator-history/idl/validator_history.json +++ b/programs/validator-history/idl/validator_history.json @@ -151,6 +151,32 @@ } ] }, + { + "name": "copy_stake_info", + "discriminator": [ + 154, + 206, + 202, + 123, + 87, + 125, + 60, + 240 + ], + "accounts": [ + { + "name": "validator_history_account", + "writable": true + }, + { + "name": "config" + }, + { + "name": "validator_stake_buffer_account" + } + ], + "args": [] + }, { "name": "copy_tip_distribution_account", "discriminator": [ @@ -316,6 +342,34 @@ ], "args": [] }, + { + "name": "initialize_validator_stake_buffer_account", + "discriminator": [ + 184, + 250, + 142, + 48, + 78, + 36, + 131, + 111 + ], + "accounts": [ + { + "name": "validator_stake_buffer_account", + "writable": true + }, + { + "name": "system_program" + }, + { + "name": "payer", + "writable": true, + "signer": true + } + ], + "args": [] + }, { "name": "realloc_cluster_history_account", "discriminator": [ @@ -410,6 +464,34 @@ ], "args": [] }, + { + "name": "realloc_validator_stake_buffer_account", + "discriminator": [ + 56, + 114, + 33, + 215, + 109, + 155, + 244, + 46 + ], + "accounts": [ + { + "name": "validator_stake_buffer_account", + "writable": true + }, + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "system_program" + } + ], + "args": [] + }, { "name": "set_new_admin", "discriminator": [ @@ -596,6 +678,32 @@ } ] }, + { + "name": "update_stake_buffer", + "discriminator": [ + 43, + 154, + 133, + 214, + 246, + 39, + 27, + 89 + ], + "accounts": [ + { + "name": "validator_stake_buffer_account", + "writable": true + }, + { + "name": "validator_history_account" + }, + { + "name": "config" + } + ], + "args": [] + }, { "name": "update_stake_history", "discriminator": [ @@ -684,6 +792,19 @@ 2, 146 ] + }, + { + "name": "ValidatorStakeBuffer", + "discriminator": [ + 53, + 175, + 63, + 177, + 129, + 250, + 119, + 110 + ] } ], "errors": [ @@ -766,9 +887,55 @@ "code": 6015, "name": "PriorityFeeDistributionAccountNotFinalized", "msg": "Priority Fee Distribution Account cannot be copied during its own epoch" + }, + { + "code": 6016, + "name": "StakeBufferFinalized", + "msg": "Stake buffer has been finalized" + }, + { + "code": 6017, + "name": "StakeBufferNotFinalized", + "msg": "Stake buffer has not been finalized" + }, + { + "code": 6018, + "name": "StakeBufferOutOfBounds", + "msg": "Stake buffer out of bounds" + }, + { + "code": 6019, + "name": "StakeBufferEmpty", + "msg": "Stake buffer is empty" + }, + { + "code": 6020, + "name": "StakeBufferDuplicate", + "msg": "Stake buffer already contains entry" } ], "types": [ + { + "name": "BitMask", + "serialization": "bytemuck", + "repr": { + "kind": "c" + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "values", + "type": { + "array": [ + "u64", + 782 + ] + } + } + ] + } + }, { "name": "CircBuf", "serialization": "bytemuck", @@ -1240,6 +1407,88 @@ } ] } + }, + { + "name": "ValidatorStake", + "serialization": "bytemuck", + "repr": { + "kind": "c" + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "validator_index", + "type": "u32" + }, + { + "name": "_padding0", + "type": "u32" + }, + { + "name": "stake_amount", + "type": "u64" + } + ] + } + }, + { + "name": "ValidatorStakeBuffer", + "serialization": "bytemuck", + "repr": { + "kind": "c" + }, + "type": { + "kind": "struct", + "fields": [ + { + "name": "last_observed_epoch", + "type": "u64" + }, + { + "name": "length", + "type": "u32" + }, + { + "name": "finalized", + "type": "u8" + }, + { + "name": "_padding0", + "type": { + "array": [ + "u8", + 131 + ] + } + }, + { + "name": "total_stake", + "type": "u64" + }, + { + "name": "inserted_validators", + "type": { + "defined": { + "name": "BitMask" + } + } + }, + { + "name": "buffer", + "type": { + "array": [ + { + "defined": { + "name": "ValidatorStake" + } + }, + 50000 + ] + } + } + ] + } } ] } \ No newline at end of file diff --git a/programs/validator-history/src/bitmask.rs b/programs/validator-history/src/bitmask.rs new file mode 100644 index 00000000..df5843ad --- /dev/null +++ b/programs/validator-history/src/bitmask.rs @@ -0,0 +1,54 @@ +use anchor_lang::{prelude::*, zero_copy}; +use borsh::BorshSerialize; + +use crate::{constants::MAX_STAKE_BUFFER_VALIDATORS, errors::ValidatorHistoryError}; + +#[allow(clippy::manual_div_ceil)] +#[allow(clippy::identity_op)] +#[allow(clippy::integer_division)] +const BITMASK_SIZE: usize = (MAX_STAKE_BUFFER_VALIDATORS + 64 - 1) / 64; + +#[derive(BorshSerialize, Debug, PartialEq)] +#[zero_copy] +pub struct BitMask { + pub values: [u64; BITMASK_SIZE], +} + +impl Default for BitMask { + fn default() -> Self { + Self { + values: [0; BITMASK_SIZE], + } + } +} + +impl BitMask { + #[allow(clippy::integer_division)] + pub fn set(&mut self, index: usize, value: bool) -> Result<()> { + if index >= MAX_STAKE_BUFFER_VALIDATORS { + return Err(ValidatorHistoryError::StakeBufferOutOfBounds.into()); + } + let word = index / 64; + let bit = index % 64; + if value { + self.values[word] |= 1 << bit; + } else { + self.values[word] &= !(1 << bit); + } + Ok(()) + } + + #[allow(clippy::integer_division)] + pub fn get(&self, index: usize) -> Result { + if index >= MAX_STAKE_BUFFER_VALIDATORS { + return Err(ValidatorHistoryError::StakeBufferOutOfBounds.into()); + } + let word = index / 64; + let bit = index % 64; + Ok((self.values[word] >> bit) & 1 == 1) + } + + pub fn reset(&mut self) { + self.values = [0; BITMASK_SIZE]; + } +} diff --git a/programs/validator-history/src/constants.rs b/programs/validator-history/src/constants.rs index 6aac1fe3..ddb779ff 100644 --- a/programs/validator-history/src/constants.rs +++ b/programs/validator-history/src/constants.rs @@ -1,3 +1,4 @@ -pub const MAX_ALLOC_BYTES: usize = 10240; +pub const MAX_ALLOC_BYTES: usize = 10_240; +pub const MAX_STAKE_BUFFER_VALIDATORS: usize = 50_000; pub const MIN_VOTE_EPOCHS: usize = 5; pub const TVC_MULTIPLIER: u32 = 16; diff --git a/programs/validator-history/src/errors.rs b/programs/validator-history/src/errors.rs index 5f9566e3..1e962be9 100644 --- a/programs/validator-history/src/errors.rs +++ b/programs/validator-history/src/errors.rs @@ -38,4 +38,14 @@ pub enum ValidatorHistoryError { PriorityFeeDistributionAccountAlreadyCopied, #[msg("Priority Fee Distribution Account cannot be copied during its own epoch")] PriorityFeeDistributionAccountNotFinalized, + #[msg("Stake buffer has been finalized")] + StakeBufferFinalized, + #[msg("Stake buffer has not been finalized")] + StakeBufferNotFinalized, + #[msg("Stake buffer out of bounds")] + StakeBufferOutOfBounds, + #[msg("Stake buffer is empty")] + StakeBufferEmpty, + #[msg("Stake buffer already contains entry")] + StakeBufferDuplicate, } diff --git a/programs/validator-history/src/instructions/copy_stake_info.rs b/programs/validator-history/src/instructions/copy_stake_info.rs new file mode 100644 index 00000000..6810bc49 --- /dev/null +++ b/programs/validator-history/src/instructions/copy_stake_info.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::ValidatorHistoryError, utils::cast_epoch, Config, ValidatorHistory, + ValidatorStakeBuffer, +}; + +// TODO: If we maintain the permissioned verion alongside this one (no oracle), anyone can +// overwrite the oracle +#[derive(Accounts)] +pub struct CopyStakeInfo<'info> { + #[account( + mut, + owner = crate::id() + )] + pub validator_history_account: AccountLoader<'info, ValidatorHistory>, + + #[account( + seeds = [Config::SEED], + bump = config.bump, + )] + pub config: Account<'info, Config>, + + #[account( + seeds = [ValidatorStakeBuffer::SEED], + bump + )] + pub validator_stake_buffer_account: AccountLoader<'info, ValidatorStakeBuffer>, +} + +pub fn handle_copy_stake_info(ctx: Context) -> Result<()> { + // Read an arbitrary validator history account + // + // No further validations required as we are simply reading an account that has already been + // created and allocated by this program + let mut validator_history_account = ctx.accounts.validator_history_account.load_mut()?; + + // Assert that we are observing vaidator stake buffer in current epoch + let epoch = Clock::get()?.epoch; + let validator_stake_buffer_account = ctx.accounts.validator_stake_buffer_account.load()?; + if epoch != validator_stake_buffer_account.last_observed_epoch() { + return Err(ValidatorHistoryError::EpochOutOfRange.into()); + } + + // Assert stake buffer is finalized + if !validator_stake_buffer_account.is_finalized() { + return Err(ValidatorHistoryError::StakeBufferNotFinalized.into()); + } + + // Look up stake info in buffer + let (stake, rank, is_superminority) = + validator_stake_buffer_account.get_by_validator_index(validator_history_account.index)?; + + // Insert and persit stake info in validator history account + let epoch = cast_epoch(epoch)?; + validator_history_account.set_stake(epoch, stake, rank, is_superminority)?; + + Ok(()) +} diff --git a/programs/validator-history/src/instructions/initialize_validator_stake_buffer_account.rs b/programs/validator-history/src/instructions/initialize_validator_stake_buffer_account.rs new file mode 100644 index 00000000..54134fae --- /dev/null +++ b/programs/validator-history/src/instructions/initialize_validator_stake_buffer_account.rs @@ -0,0 +1,28 @@ +use anchor_lang::prelude::*; + +use crate::{constants::MAX_ALLOC_BYTES, ValidatorStakeBuffer}; + +#[derive(Accounts)] +pub struct InitializeValidatorStakeBufferAccount<'info> { + #[account( + init, + payer = payer, + space = MAX_ALLOC_BYTES, + seeds = [ValidatorStakeBuffer::SEED], + bump + )] + pub validator_stake_buffer_account: AccountLoader<'info, ValidatorStakeBuffer>, + pub system_program: Program<'info, System>, + #[account(mut)] + pub payer: Signer<'info>, +} + +/// Initializes the [ValidatorStakeBuffer] account +/// +/// Leaves data zero initialized, as the account needs to reallocated many times before we can +/// start using the buffer for aggregation of validator stake. +pub fn handle_initialize_validator_stake_buffer_account( + _ctx: Context, +) -> Result<()> { + Ok(()) +} diff --git a/programs/validator-history/src/instructions/mod.rs b/programs/validator-history/src/instructions/mod.rs index 2d816cd1..b9b9baf4 100644 --- a/programs/validator-history/src/instructions/mod.rs +++ b/programs/validator-history/src/instructions/mod.rs @@ -2,38 +2,46 @@ pub mod backfill_total_blocks; pub mod copy_cluster_info; pub mod copy_gossip_contact_info; pub mod copy_priority_fee_distribution; +pub mod copy_stake_info; pub mod copy_tip_distribution_account; pub mod copy_vote_account; pub mod initialize_cluster_history_account; pub mod initialize_config; pub mod initialize_validator_history_account; +pub mod initialize_validator_stake_buffer_account; pub mod realloc_cluster_history_account; pub mod realloc_config_account; pub mod realloc_validator_history_account; +pub mod realloc_validator_stake_buffer_account; pub mod set_new_admin; pub mod set_new_oracle_authority; pub mod set_new_priority_fee_distribution_program; pub mod set_new_priority_fee_oracle_authority; pub mod set_new_tip_distribution_program; pub mod update_priority_fee_history; +pub mod update_stake_buffer; pub mod update_stake_history; pub use backfill_total_blocks::*; pub use copy_cluster_info::*; pub use copy_gossip_contact_info::*; pub use copy_priority_fee_distribution::*; +pub use copy_stake_info::*; pub use copy_tip_distribution_account::*; pub use copy_vote_account::*; pub use initialize_cluster_history_account::*; pub use initialize_config::*; pub use initialize_validator_history_account::*; +pub use initialize_validator_stake_buffer_account::*; pub use realloc_cluster_history_account::*; pub use realloc_config_account::*; pub use realloc_validator_history_account::*; +pub use realloc_validator_stake_buffer_account::*; pub use set_new_admin::*; pub use set_new_oracle_authority::*; pub use set_new_priority_fee_distribution_program::*; pub use set_new_priority_fee_oracle_authority::*; pub use set_new_tip_distribution_program::*; pub use update_priority_fee_history::*; +pub use update_stake_buffer::*; pub use update_stake_history::*; diff --git a/programs/validator-history/src/instructions/realloc_validator_stake_buffer_account.rs b/programs/validator-history/src/instructions/realloc_validator_stake_buffer_account.rs new file mode 100644 index 00000000..78d9ac97 --- /dev/null +++ b/programs/validator-history/src/instructions/realloc_validator_stake_buffer_account.rs @@ -0,0 +1,71 @@ +use anchor_lang::prelude::*; + +use crate::{constants::MAX_ALLOC_BYTES, errors::ValidatorHistoryError, ValidatorStakeBuffer}; + +fn get_realloc_size(account_info: &AccountInfo) -> usize { + let account_size = account_info.data_len(); + // If account is already over-allocated, don't try to shrink + if account_size < ValidatorStakeBuffer::SIZE { + ValidatorStakeBuffer::SIZE.min(account_size + MAX_ALLOC_BYTES) + } else { + account_size + } +} + +fn is_initialized(account_info: &AccountInfo) -> Result { + let account_data = account_info.as_ref().try_borrow_data()?; + // Parse last-observed-epoch bytes (first u64 field after discriminator) + let epoch_bytes = account_data[8..16].to_vec(); + // Check for any non-zero bytes + let non_zero = epoch_bytes.iter().any(|&x| x.ne(&0)); + Ok(non_zero) +} + +#[derive(Accounts)] +pub struct ReallocValidatorStakeBufferAccount<'info> { + #[account( + mut, + realloc = get_realloc_size(validator_stake_buffer_account.as_ref()), + realloc::payer = payer, + realloc::zero = false, + seeds = [ValidatorStakeBuffer::SEED], + bump + )] + pub validator_stake_buffer_account: AccountLoader<'info, ValidatorStakeBuffer>, + #[account(mut)] + pub payer: Signer<'info>, + pub system_program: Program<'info, System>, +} + +pub fn handle_realloc_validator_stake_buffer_account( + ctx: Context, +) -> Result<()> { + let account_size = ctx + .accounts + .validator_stake_buffer_account + .as_ref() + .data_len(); + // Determine if account is sufficiently sized and/or initialized + let big_enough = account_size >= ValidatorStakeBuffer::SIZE; + let initialized = is_initialized(ctx.accounts.validator_stake_buffer_account.as_ref())?; + match (big_enough, initialized) { + // Not big enough + (false, _) => { + // Keep moving ... + } + // Big enough but not initialized yet + (true, false) => { + // Can actually initialze values now that the account is proper size + let mut validator_stake_buffer_account = + ctx.accounts.validator_stake_buffer_account.load_mut()?; + // Indicate that the account has been initialized + // by uniquely setting the last-observed-epoch value to 1 + validator_stake_buffer_account.reset(1u64); + } + // Already initialized + (true, true) => { + return Err(ValidatorHistoryError::NoReallocNeeded.into()); + } + } + Ok(()) +} diff --git a/programs/validator-history/src/instructions/update_stake_buffer.rs b/programs/validator-history/src/instructions/update_stake_buffer.rs new file mode 100644 index 00000000..168eb1fa --- /dev/null +++ b/programs/validator-history/src/instructions/update_stake_buffer.rs @@ -0,0 +1,53 @@ +use anchor_lang::prelude::*; +use anchor_lang::solana_program::epoch_stake::get_epoch_stake_for_vote_account; + +use crate::{Config, ValidatorHistory, ValidatorStake, ValidatorStakeBuffer}; + +#[derive(Accounts)] +pub struct UpdateStakeBuffer<'info> { + #[account( + mut, + seeds = [ValidatorStakeBuffer::SEED], + bump + )] + pub validator_stake_buffer_account: AccountLoader<'info, ValidatorStakeBuffer>, + + #[account(owner = crate::id())] + pub validator_history_account: AccountLoader<'info, ValidatorHistory>, + + #[account( + seeds = [Config::SEED], + bump = config.bump, + )] + pub config: Account<'info, Config>, +} + +pub fn handle_update_stake_buffer(ctx: Context) -> Result<()> { + // Get validator vote account and index for insertion + let validator_history = ctx.accounts.validator_history_account.load()?; + let validator_index = validator_history.index; + let vote_account_pubkey = validator_history.vote_account; + + // Build insert context + let config = &ctx.accounts.config; + let mut validator_stake_buffer = ctx.accounts.validator_stake_buffer_account.load_mut()?; + + // Validate buffer against epoch + let epoch = Clock::get()?.epoch; + if validator_stake_buffer.needs_reset(epoch) { + // Reset buffer + validator_stake_buffer.reset(epoch); + } + + // Observe vote account stake + // + // If the provided vote address corresponds to an account that is not a vote + // account or does not exist, returns `0` for active stake + let stake_amount: u64 = get_epoch_stake_for_vote_account(&vote_account_pubkey); + + // Insert into buffer + let entry = ValidatorStake::new(validator_index, stake_amount); + validator_stake_buffer.insert(config, entry)?; + + Ok(()) +} diff --git a/programs/validator-history/src/lib.rs b/programs/validator-history/src/lib.rs index 21ddaedd..894ba24e 100644 --- a/programs/validator-history/src/lib.rs +++ b/programs/validator-history/src/lib.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; mod allocator; +pub mod bitmask; pub mod constants; pub mod crds_value; pub mod errors; @@ -66,6 +67,18 @@ pub mod validator_history { handle_realloc_cluster_history_account(ctx) } + pub fn initialize_validator_stake_buffer_account( + ctx: Context, + ) -> Result<()> { + handle_initialize_validator_stake_buffer_account(ctx) + } + + pub fn realloc_validator_stake_buffer_account( + ctx: Context, + ) -> Result<()> { + handle_realloc_validator_stake_buffer_account(ctx) + } + pub fn copy_vote_account(ctx: Context) -> Result<()> { handle_copy_vote_account(ctx) } @@ -95,6 +108,14 @@ pub mod validator_history { handle_set_new_oracle_authority(ctx) } + pub fn copy_stake_info(ctx: Context) -> Result<()> { + handle_copy_stake_info(ctx) + } + + pub fn update_stake_buffer(ctx: Context) -> Result<()> { + handle_update_stake_buffer(ctx) + } + pub fn update_stake_history( ctx: Context, epoch: u64, diff --git a/programs/validator-history/src/state.rs b/programs/validator-history/src/state.rs index 09bb8ff3..6a31c61b 100644 --- a/programs/validator-history/src/state.rs +++ b/programs/validator-history/src/state.rs @@ -6,7 +6,8 @@ use anchor_lang::idl::{ use { crate::{ - constants::TVC_MULTIPLIER, + bitmask::BitMask, + constants::{MAX_STAKE_BUFFER_VALIDATORS, TVC_MULTIPLIER}, crds_value::{ContactInfo, LegacyContactInfo, LegacyVersion, Version2}, errors::ValidatorHistoryError, utils::{cast_epoch, find_insert_position, get_max_epoch, get_min_epoch}, @@ -1358,6 +1359,185 @@ impl ClusterHistory { } } +#[derive(BorshSerialize, Debug, Default, PartialEq)] +#[zero_copy] +pub struct ValidatorStake { + pub validator_index: u32, + _padding0: u32, + pub stake_amount: u64, +} + +impl ValidatorStake { + pub fn new(validator_index: u32, stake_amount: u64) -> Self { + Self { + validator_index, + _padding0: 0, + stake_amount, + } + } +} + +#[derive(BorshSerialize, Debug, PartialEq)] +#[account(zero_copy)] +pub struct ValidatorStakeBuffer { + // Most recent epoch observed when aggregating stake amounts + // If this doesn't equal the current epoch, reset + last_observed_epoch: u64, + + // Length of the stake buffer (number of validators observed this epoch) + length: u32, + + // Indicates whether or not we've observed every validator (history) account + // This provides finality of stake observations + finalized: u8, /* boolean */ + + _padding0: [u8; 131], + + // Accumulator of total stake in pool + // Not useful until buffer is finalized + total_stake: u64, + + // Bitmask of inserted validators + inserted_validators: BitMask, + + // Sorted validator stake amounts (descending by amount) + buffer: [ValidatorStake; MAX_STAKE_BUFFER_VALIDATORS], +} + +impl Default for ValidatorStakeBuffer { + fn default() -> Self { + Self { + last_observed_epoch: 0, + length: 0, + finalized: 0, + _padding0: [0; 131], + total_stake: 0, + inserted_validators: BitMask::default(), + buffer: [ValidatorStake::default(); MAX_STAKE_BUFFER_VALIDATORS], + } + } +} + +/// (Stake Lamports, Rank, Superminority) +type ValidatorRank = (u64, u32, bool); + +impl ValidatorStakeBuffer { + pub const SEED: &'static [u8] = b"validator-stake-buffer"; + pub const SIZE: usize = 8 + size_of::(); + + /// Resets aggregation for new epoch + pub fn reset(&mut self, epoch: u64) { + self.last_observed_epoch = epoch; + self.total_stake = 0; + self.length = 0; + self.finalized = 0; + self.inserted_validators.reset(); + self.buffer = [ValidatorStake::default(); MAX_STAKE_BUFFER_VALIDATORS]; + } + + pub fn is_finalized(&self) -> bool { + self.finalized == 1 + } + + pub fn length(&self) -> u32 { + self.length + } + + pub fn size(&self) -> usize { + self.buffer.len() + } + + pub fn last_observed_epoch(&self) -> u64 { + self.last_observed_epoch + } + + pub fn needs_reset(&self, epoch: u64) -> bool { + epoch > self.last_observed_epoch + } + + /// Get element by positional index in buffer + pub fn get(&self, index: usize) -> Result { + if index >= self.length as usize { + return Err(ValidatorHistoryError::StakeBufferOutOfBounds.into()); + } + Ok(self.buffer[index]) + } + + pub fn total_stake(&self) -> u64 { + self.total_stake + } + + /// Get element by validator index + /// + /// Linear searches thru buffer for rank + pub fn get_by_validator_index(&self, validator_index: u32) -> Result { + let total_stake = self.total_stake(); + if total_stake == 0 { + return Err(ValidatorHistoryError::StakeBufferEmpty.into()); + } + // Accumulators + let mut cumulative_stake: u64 = 0; + let mut is_superminority = true; + let superminority_threshold = total_stake / 3; + // Search for validator rank and superminority threshold + for rank in 0..self.length as usize { + let entry = &self.buffer[rank]; + // Superminority threshold check + if cumulative_stake > superminority_threshold && is_superminority { + is_superminority = false; + } else { + cumulative_stake = cumulative_stake.saturating_add(entry.stake_amount); + } + // Rank + if entry.validator_index == validator_index { + return Ok((entry.stake_amount, rank as u32, is_superminority)); + } + } + Err(ValidatorHistoryError::StakeBufferOutOfBounds.into()) + } + + /// Inserts a new [ValidatorStake] entry into the buffer + /// + /// Validates against bitmask to prevent duplicate assertions, + /// and marks buffer as finalized when length equals total validator history count. + pub fn insert(&mut self, config: &Config, entry: ValidatorStake) -> Result<()> { + // Early exit if finalized + if self.is_finalized() { + return Err(ValidatorHistoryError::StakeBufferFinalized.into()); + } + // Check for duplicate entry before insertion + if self + .inserted_validators + .get(entry.validator_index as usize)? + { + return Err(ValidatorHistoryError::StakeBufferDuplicate.into()); + } + self.inserted_validators + .set(entry.validator_index as usize, true)?; + // Start linear search from end of buffer until finding validator with greater or equal stake + // to insert the new entry while maintaining descending order + let mut i = self.length as usize; + while i > 0 && entry.stake_amount > self.buffer[i - 1].stake_amount { + // Shift element to the right one to make space + self.buffer[i] = self.buffer[i - 1]; + i -= 1; + } + // Insert entry + self.buffer[i] = entry; + self.length += 1; + // Increment total stake + let mut total_stake = self.total_stake; + total_stake = total_stake.saturating_add(entry.stake_amount); + self.total_stake = total_stake; + // Set finalized flag if the buffer is now full + let max_length = config.counter.min(MAX_STAKE_BUFFER_VALIDATORS as u32); + if self.length == max_length { + self.finalized = 1; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/src/validator_history_fixtures.rs b/tests/src/validator_history_fixtures.rs index 22fecdd2..905e8fab 100644 --- a/tests/src/validator_history_fixtures.rs +++ b/tests/src/validator_history_fixtures.rs @@ -13,20 +13,27 @@ use { }, jito_tip_distribution::state::{MerkleRoot, TipDistributionAccount}, jito_tip_distribution_sdk::derive_tip_distribution_account_address, + solana_program::rent::Rent, solana_program_test::*, solana_sdk::{ - account::Account, instruction::Instruction, signature::Keypair, signer::Signer, + account::Account, hash::Hash, instruction::Instruction, signature::Keypair, signer::Signer, transaction::Transaction, }, std::{cell::RefCell, rc::Rc}, - validator_history::{self, constants::MAX_ALLOC_BYTES, ClusterHistory, ValidatorHistory}, + validator_history::{ + self, + constants::{MAX_ALLOC_BYTES, MAX_STAKE_BUFFER_VALIDATORS}, + ClusterHistory, ValidatorHistory, ValidatorStakeBuffer, + }, }; pub struct TestFixture { pub ctx: Rc>, pub vote_account: Pubkey, + pub additional_vote_accounts: Vec, pub identity_keypair: Keypair, pub cluster_history_account: Pubkey, + pub validator_stake_buffer_account: Pubkey, pub validator_history_account: Pubkey, pub validator_history_config: Pubkey, pub tip_distribution_account: Pubkey, @@ -53,6 +60,7 @@ impl TestFixture { None, ); + // Derive pubkeys let epoch = 0; let vote_account = Pubkey::new_unique(); let identity_keypair = Keypair::new(); @@ -78,29 +86,53 @@ impl TestFixture { &validator_history::id(), ) .0; + let validator_stake_buffer_account = Pubkey::find_program_address( + &[validator_history::state::ValidatorStakeBuffer::SEED], + &validator_history::id(), + ) + .0; let keypair = Keypair::new(); let priority_fee_oracle_keypair = Keypair::new(); + // Add accounts program.add_account( vote_account, new_vote_account(identity_pubkey, vote_account, 1, Some(vec![(0, 0, 0); 10])), ); - program.add_account(keypair.pubkey(), system_account(100_000_000_000)); - program.add_account(identity_pubkey, system_account(100_000_000_000)); + program.add_account(keypair.pubkey(), system_account(900_000_000_000_000_000)); + program.add_account(identity_pubkey, system_account(900_000_000_000_000_000)); program.add_account( priority_fee_oracle_keypair.pubkey(), - system_account(100_000_000_000), + system_account(900_000_000_000_000_000), ); - let ctx = Rc::new(RefCell::new(program.start_with_context().await)); + // Add vec of additional vote accounts + let mut additional_vote_accounts = vec![]; + for _ in 0..MAX_STAKE_BUFFER_VALIDATORS { + let keypair = Keypair::new(); + program.add_account( + keypair.pubkey(), + new_vote_account( + identity_pubkey, + keypair.pubkey(), + 1, + Some(vec![(0, 0, 0); 10]), + ), + ); + additional_vote_accounts.push(keypair.pubkey()); + } + // Start program + let ctx = Rc::new(RefCell::new(program.start_with_context().await)); Self { ctx, validator_history_config, validator_history_account, cluster_history_account, + validator_stake_buffer_account, identity_keypair, vote_account, + additional_vote_accounts, tip_distribution_account, keypair, priority_fee_oracle_keypair, @@ -201,6 +233,54 @@ impl TestFixture { self.submit_transaction_assert_success(transaction).await; } + pub fn build_initialize_validator_stake_buffer_account_instruction(&self) -> Instruction { + Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::ReallocValidatorStakeBufferAccount { + validator_stake_buffer_account: self.validator_stake_buffer_account, + system_program: anchor_lang::solana_program::system_program::id(), + payer: self.keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::ReallocValidatorStakeBufferAccount {}.data(), + } + } + + pub fn build_initialize_and_realloc_validator_stake_buffer_account_transaction( + &self, + ) -> Vec Transaction + use<'_>> { + let mut ixs = vec![]; + let init_ix = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::InitializeValidatorStakeBufferAccount { + validator_stake_buffer_account: self.validator_stake_buffer_account, + system_program: anchor_lang::solana_program::system_program::id(), + payer: self.keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::InitializeValidatorStakeBufferAccount {}.data(), + }; + ixs.push(init_ix); + let num_reallocs = (ValidatorStakeBuffer::SIZE - MAX_ALLOC_BYTES) / MAX_ALLOC_BYTES + 1; + let realloc_ixs = + vec![self.build_initialize_validator_stake_buffer_account_instruction(); num_reallocs]; + ixs.extend_from_slice(realloc_ixs.as_slice()); + let mut transactions = vec![]; + for chunk in ixs.chunks(10) { + let chunk = chunk.to_vec(); + let tx = move |hash| { + Transaction::new_signed_with_payer( + chunk.as_slice(), + Some(&self.keypair.pubkey()), + &[&self.keypair], + hash, + ) + }; + transactions.push(tx); + } + transactions + } + pub async fn initialize_validator_history_account(&self) { let instruction = Instruction { program_id: validator_history::id(), @@ -357,6 +437,16 @@ impl TestFixture { panic!("Error: Transaction succeeded. Expected {}", error_message); } } + + #[allow(clippy::await_holding_refcell_ref)] + pub async fn fresh_blockhash(&self) -> Hash { + self.ctx + .borrow() + .banks_client + .get_latest_blockhash() + .await + .unwrap() + } } pub fn system_account(lamports: u64) -> Account { @@ -393,11 +483,14 @@ pub fn new_vote_account( vote_state.epoch_credits = epoch_credits; } let vote_state_versions = VoteStateVersions::new_current(vote_state); - let mut data = vec![0; VoteState::size_of()]; + let data_len = VoteState::size_of(); + let rent_exempt = Rent::default().minimum_balance(data_len); + + let mut data = vec![0; data_len]; VoteState::serialize(&vote_state_versions, &mut data).unwrap(); Account { - lamports: 1000000, + lamports: rent_exempt, data, owner: anchor_lang::solana_program::vote::program::ID, ..Account::default() diff --git a/tests/tests/validator_history/mod.rs b/tests/tests/validator_history/mod.rs index 9386d339..3c81fc33 100644 --- a/tests/tests/validator_history/mod.rs +++ b/tests/tests/validator_history/mod.rs @@ -7,6 +7,7 @@ mod test_realloc_config; mod test_set_new_priority_fee_oracle_authority; mod test_set_new_priority_fee_program; mod test_stake; +mod test_stake_buffer; mod test_total_priority_fees; mod test_vote_account; diff --git a/tests/tests/validator_history/test_initialize.rs b/tests/tests/validator_history/test_initialize.rs index d3136d6e..eac10f77 100644 --- a/tests/tests/validator_history/test_initialize.rs +++ b/tests/tests/validator_history/test_initialize.rs @@ -3,7 +3,10 @@ use anchor_lang::{solana_program::instruction::Instruction, InstructionData, ToA use solana_program_test::*; use solana_sdk::{signer::Signer, transaction::Transaction}; use tests::validator_history_fixtures::{new_vote_account, TestFixture}; -use validator_history::{constants::MAX_ALLOC_BYTES, Config, ValidatorHistory}; +use validator_history::{ + constants::{MAX_ALLOC_BYTES, MAX_STAKE_BUFFER_VALIDATORS}, + Config, ValidatorHistory, ValidatorStakeBuffer, +}; #[tokio::test] async fn test_initialize() { @@ -42,7 +45,6 @@ async fn test_initialize() { assert!(config.admin == test.keypair.pubkey()); // Initialize validator history account - let instruction = Instruction { program_id: validator_history::id(), accounts: validator_history::accounts::InitializeValidatorHistoryAccount { @@ -62,7 +64,32 @@ async fn test_initialize() { ); test.submit_transaction_assert_success(transaction).await; - // Get account and Assert exists + // Initialize stake buffer account + for tx in test + .build_initialize_and_realloc_validator_stake_buffer_account_transaction() + .into_iter() + { + let hash = test.fresh_blockhash().await; + let tx = tx(hash); + test.submit_transaction_assert_success(tx).await; + } + + // Get stake aggregation account and assert exists and zero initialized + let account = ctx + .borrow_mut() + .banks_client + .get_account(test.validator_stake_buffer_account) + .await + .unwrap(); + assert!(account.is_some()); + let account = account.unwrap(); + assert!(account.owner == validator_history::id()); + assert!(account.data.len() >= MAX_ALLOC_BYTES); + let account = + bytemuck::try_from_bytes::(&account.data.as_slice()[8..]).unwrap(); + assert!(account.size() == MAX_STAKE_BUFFER_VALIDATORS); + + // Get validator history account and assert exists let account = ctx .borrow_mut() .banks_client @@ -147,7 +174,35 @@ async fn test_initialize_fail() { } #[tokio::test] -async fn test_extra_realloc() { +async fn test_extra_realloc_validator_stake_buffer() { + let fixture = TestFixture::new().await; + let ctx = &fixture.ctx; + + // Initialize and relloc to the limit + for tx in fixture + .build_initialize_and_realloc_validator_stake_buffer_account_transaction() + .into_iter() + { + let hash = fixture.fresh_blockhash().await; + let tx = tx(hash); + fixture.submit_transaction_assert_success(tx).await; + } + + // Assert than an additional realloc fails + let instruction = fixture.build_initialize_validator_stake_buffer_account_instruction(); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&fixture.keypair.pubkey()), + &[&fixture.keypair], + ctx.borrow().last_blockhash, + ); + fixture + .submit_transaction_assert_error(transaction, "NoReallocNeeded") + .await; +} + +#[tokio::test] +async fn test_extra_realloc_validator_history() { let fixture = TestFixture::new().await; let ctx = &fixture.ctx; fixture.initialize_config().await; diff --git a/tests/tests/validator_history/test_stake_buffer.rs b/tests/tests/validator_history/test_stake_buffer.rs new file mode 100644 index 00000000..21a3f418 --- /dev/null +++ b/tests/tests/validator_history/test_stake_buffer.rs @@ -0,0 +1,563 @@ +use anchor_lang::{InstructionData, ToAccountMetas}; +use solana_program::sysvar::clock::Clock; +use solana_program_test::*; +use solana_sdk::stake::{ + self, instruction as stake_instruction, + state::{Authorized, Lockup, StakeStateV2}, +}; +use solana_sdk::{ + instruction::Instruction, + pubkey::Pubkey, + signer::{keypair::Keypair, Signer}, + system_instruction, + transaction::Transaction, +}; +use std::cell::RefCell; +use std::rc::Rc; + +use solana_sdk::hash::Hash; + +use tests::validator_history_fixtures::TestFixture; +use validator_history::constants::MAX_ALLOC_BYTES; +use validator_history::state::{ValidatorHistory, ValidatorStakeBuffer}; + +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +pub async fn create_validator_accounts( + ctx: &Rc>, + payer: &Keypair, + validator_history_config: &Pubkey, + vote_account: &Pubkey, + stake_amount: u64, + hash: Hash, +) -> Pubkey { + let _ = create_stake_account(ctx, payer, vote_account, stake_amount, hash).await; + create_validator_history_account(ctx, payer, vote_account, validator_history_config, hash).await +} + +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +pub async fn create_validator_history_account( + ctx: &Rc>, + payer: &Keypair, + vote_account: &Pubkey, + validator_history_config: &Pubkey, + hash: Hash, +) -> Pubkey { + let validator_history_account = Pubkey::find_program_address( + &[ValidatorHistory::SEED, vote_account.as_ref()], + &validator_history::id(), + ) + .0; + let instruction = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::InitializeValidatorHistoryAccount { + validator_history_account, + vote_account: *vote_account, + system_program: anchor_lang::solana_program::system_program::id(), + signer: payer.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::InitializeValidatorHistoryAccount {}.data(), + }; + let mut ixs = vec![instruction]; + let num_reallocs = (ValidatorHistory::SIZE - MAX_ALLOC_BYTES) / MAX_ALLOC_BYTES + 1; + ixs.extend(vec![ + Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::ReallocValidatorHistoryAccount { + validator_history_account, + vote_account: *vote_account, + config: *validator_history_config, + system_program: anchor_lang::solana_program::system_program::id(), + signer: payer.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::ReallocValidatorHistoryAccount {}.data(), + }; + num_reallocs + ]); + let tx = Transaction::new_signed_with_payer(&ixs, Some(&payer.pubkey()), &[payer], hash); + ctx.borrow_mut() + .banks_client + .process_transaction(tx) + .await + .unwrap(); + validator_history_account +} + +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +pub async fn create_stake_account( + ctx: &Rc>, + payer: &Keypair, + vote_account: &Pubkey, + stake_amount: u64, + hash: Hash, +) -> Pubkey { + let stake_account = Keypair::new(); + let rent = ctx.borrow().banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(StakeStateV2::size_of()); + let lamports_to_delegate = stake_amount + stake_rent; + let authorized = Authorized { + staker: payer.pubkey(), + withdrawer: payer.pubkey(), + }; + let lockup = Lockup::default(); + let instructions = vec![ + system_instruction::create_account( + &payer.pubkey(), + &stake_account.pubkey(), + lamports_to_delegate, + StakeStateV2::size_of() as u64, + &stake::program::id(), + ), + stake_instruction::initialize(&stake_account.pubkey(), &authorized, &lockup), + stake_instruction::delegate_stake(&stake_account.pubkey(), &payer.pubkey(), vote_account), + ]; + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &stake_account], + hash, + ); + ctx.borrow_mut() + .banks_client + .process_transaction(tx) + .await + .unwrap(); + stake_account.pubkey() +} + +/// This test inserts monotonically decreasing stake amounts into the buffer, which is best case +/// scenario in terms of compute units consumed. +/// +/// We have observed that CUs remain constant for every insertion regardless of buffer size or +/// length. Roughly 17_000 CUs. +#[tokio::test] +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +async fn test_stake_buffer_insert_cu_limit_min() { + let test = TestFixture::new().await; + + // Initialize validator history config and stake buffer accounts + test.initialize_config().await; + for tx in test + .build_initialize_and_realloc_validator_stake_buffer_account_transaction() + .into_iter() + { + let hash = test.fresh_blockhash().await; + let tx = tx(hash); + test.submit_transaction_assert_success(tx).await; + } + + // Create several mock validator history accounts + let num_validators = 10; + let mut validator_accounts = Vec::new(); + for (i, vote_account) in test + .additional_vote_accounts + .clone() + .iter() + .enumerate() + .take(num_validators) + { + // Set linearly decreasing stake amounts + // such that we are always directly pushing to the end of the buffer, + // simulating the optimal case. + // Notice in the logs, that CUs per insert instruction are constant + // as opposed to the lineary increasing test case where CUs are linearly increasing with every + // sequential insert instruction. + let stake_amount = (100 * 100_000_000) - i as u64; + let hash = test.fresh_blockhash().await; + let validator_history_address = create_validator_accounts( + &test.ctx, + &test.keypair, + &test.validator_history_config, + vote_account, + stake_amount, + hash, + ) + .await; + + validator_accounts.push((*vote_account, validator_history_address)); + } + // Advance epoch to finalize stake delegations + test.advance_num_epochs(1).await; + + // Insert validators into stake buffer + for (_vote_account_address, validator_history_address) in validator_accounts.iter() { + let ix_data = validator_history::instruction::UpdateStakeBuffer {}; + let accounts = validator_history::accounts::UpdateStakeBuffer { + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + validator_history_account: *validator_history_address, + }; + let metas = accounts.to_account_metas(None); + let latest_blockhash = test.fresh_blockhash().await; + let transaction = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + 1_400_000, + ), + Instruction { + program_id: validator_history::id(), + accounts: metas, + data: ix_data.data(), + }, + ], + Some(&test.keypair.pubkey()), + &[&test.keypair], + latest_blockhash, + ); + test.submit_transaction_assert_success(transaction).await; + } + + // Deserialize buffer account + let stake_buffer_account: ValidatorStakeBuffer = test + .load_and_deserialize(&test.validator_stake_buffer_account) + .await; + let current_epoch = test + .ctx + .borrow_mut() + .banks_client + .get_sysvar::() + .await + .unwrap() + .epoch; + + // Assert total stake amount + let base_stake_per_validator = 100 * 100_000_000; + let sum_of_decrements = num_validators as u64 * (num_validators as u64 - 1) / 2; /* sum of arithmetic series */ + let expected_total_stake = num_validators as u64 * base_stake_per_validator - sum_of_decrements; + assert_eq!(stake_buffer_account.length(), num_validators as u32); + assert_eq!(stake_buffer_account.last_observed_epoch(), current_epoch); + assert_eq!(stake_buffer_account.total_stake(), expected_total_stake); + + // Assert each entry + for i in 0..stake_buffer_account.length() { + let acc = stake_buffer_account.get(i as usize).unwrap(); + let expected = 100 * 100_000_000 - i as u64; + println!("expected: {}", expected); + println!("actual: {}", acc.stake_amount); + assert!(acc.stake_amount == expected); + } +} + +/// This test was used to max out the size of the stake buffer by measuring the consumption of +/// compute units when inserting into the buffer with the buffer size set to 50_000 validators. +/// +/// Because this test inserts validators with monotonically increasing stake amounts, it forces the +/// insert instruction into the worst case on invocation. +/// +/// We observed linearly increasing CUs up to the 50_000 element, maxing out at just over 700_000 CUs. +/// This is about half of the max CUs that a single transaction is permitted to consume, which is +/// sweet spot between maintaining a huge buffer allowing for growth of the protocol while still +/// remaining well within the CU bounds. +/// +/// This test is now nerfed down to 10 validators, as it still serves as a useful integration test. +/// Scaling up to 50_000 validators take about 10 minutes to run ... which is not practical for CI +/// pipelines. +#[tokio::test] +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +async fn test_stake_buffer_insert_until_cu_limit_max() { + let test = TestFixture::new().await; + + // Initialize validator history config and stake buffer accounts + test.initialize_config().await; + for tx in test + .build_initialize_and_realloc_validator_stake_buffer_account_transaction() + .into_iter() + { + let hash = test.fresh_blockhash().await; + let tx = tx(hash); + test.submit_transaction_assert_success(tx).await; + } + + // Create several mock validator history accounts + let num_validators = 10; + let mut validator_accounts = Vec::new(); + for (i, vote_account) in test + .additional_vote_accounts + .clone() + .iter() + .enumerate() + .take(num_validators) + { + // Set linearly increasing stake amounts + // such that we iterate the entire buffer onchain on every insert instruction, simulating + // the worst cast scenario and guaranteeing that we have actually maxed out the buffer + // size. + let stake_amount = (10 * 100_000_000) + i as u64; + let hash = test.fresh_blockhash().await; + let validator_history_address = create_validator_accounts( + &test.ctx, + &test.keypair, + &test.validator_history_config, + vote_account, + stake_amount, + hash, + ) + .await; + + validator_accounts.push((*vote_account, validator_history_address)); + } + // Advance epoch to finalize stake delegations + test.advance_num_epochs(1).await; + + // Insert validators into stake buffer + for (_vote_account_address, validator_history_address) in validator_accounts.iter() { + let ix_data = validator_history::instruction::UpdateStakeBuffer {}; + let accounts = validator_history::accounts::UpdateStakeBuffer { + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + validator_history_account: *validator_history_address, + }; + let metas = accounts.to_account_metas(None); + let latest_blockhash = test.fresh_blockhash().await; + let transaction = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + 1_400_000, + ), + Instruction { + program_id: validator_history::id(), + accounts: metas, + data: ix_data.data(), + }, + ], + Some(&test.keypair.pubkey()), + &[&test.keypair], + latest_blockhash, + ); + test.submit_transaction_assert_success(transaction).await; + } + + // Deserialize buffer account + let stake_buffer_account: ValidatorStakeBuffer = test + .load_and_deserialize(&test.validator_stake_buffer_account) + .await; + let current_epoch = test + .ctx + .borrow_mut() + .banks_client + .get_sysvar::() + .await + .unwrap() + .epoch; + + // Assert total stake amount + let base_stake_per_validator = 10 * 100_000_000; + let sum_of_increments = num_validators as u64 * (num_validators as u64 - 1) / 2; /* sum of arithmetic series */ + let expected_total_stake = num_validators as u64 * base_stake_per_validator + sum_of_increments; + assert_eq!(stake_buffer_account.length(), num_validators as u32); + assert_eq!(stake_buffer_account.last_observed_epoch(), current_epoch); + assert_eq!(stake_buffer_account.total_stake(), expected_total_stake); + + // Assert each entry + for i in 0..stake_buffer_account.length() { + let acc = stake_buffer_account.get(i as usize).unwrap(); + let expected = 10 * 100_000_000 + (10 - i as u64 - 1); + assert!(acc.stake_amount == expected); + } +} + +#[tokio::test] +#[allow(clippy::too_many_arguments, clippy::await_holding_refcell_ref)] +async fn test_copy_stake_info() { + let test = TestFixture::new().await; + + // Initialize validator history config and stake buffer accounts + test.initialize_config().await; + for tx in test + .build_initialize_and_realloc_validator_stake_buffer_account_transaction() + .into_iter() + { + let hash = test.fresh_blockhash().await; + let tx = tx(hash); + test.submit_transaction_assert_success(tx).await; + } + + // Create several mock validator history accounts + let num_validators = 10; + let mut validator_accounts = Vec::new(); + for (i, vote_account) in test + .additional_vote_accounts + .clone() + .iter() + .enumerate() + .take(num_validators) + { + // Set linearly increasing stake amounts + // such that we iterate the entire buffer onchain on every insert instruction, simulating + // the worst cast scenario and guaranteeing that we have actually maxed out the buffer + // size. + let stake_amount = (10 * 100_000_000) + i as u64; + let hash = test.fresh_blockhash().await; + let validator_history_address = create_validator_accounts( + &test.ctx, + &test.keypair, + &test.validator_history_config, + vote_account, + stake_amount, + hash, + ) + .await; + + validator_accounts.push((*vote_account, validator_history_address)); + } + // Advance epoch to finalize stake delegations + test.advance_num_epochs(1).await; + + // Insert validators into stake buffer + for (_vote_account_address, validator_history_address) in validator_accounts.iter() { + let ix_data = validator_history::instruction::UpdateStakeBuffer {}; + let accounts = validator_history::accounts::UpdateStakeBuffer { + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + validator_history_account: *validator_history_address, + }; + let metas = accounts.to_account_metas(None); + let latest_blockhash = test.fresh_blockhash().await; + let transaction = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + 1_400_000, + ), + Instruction { + program_id: validator_history::id(), + accounts: metas, + data: ix_data.data(), + }, + ], + Some(&test.keypair.pubkey()), + &[&test.keypair], + latest_blockhash, + ); + test.submit_transaction_assert_success(transaction).await; + } + + // Deserialize buffer account + let stake_buffer_account: ValidatorStakeBuffer = test + .load_and_deserialize(&test.validator_stake_buffer_account) + .await; + + // Copy stake info from buffer into validator history accounts + for (_, validator_history_address) in validator_accounts.iter() { + // Build copy stake info instruction + let instruction = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::CopyStakeInfo { + validator_history_account: *validator_history_address, + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + } + .to_account_metas(None), + data: validator_history::instruction::CopyStakeInfo {}.data(), + }; + // Pack transaction + let hash = test.fresh_blockhash().await; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&test.keypair.pubkey()), + &[&test.keypair], + hash, + ); + test.submit_transaction_assert_success(transaction).await; + // Assert values + let account: ValidatorHistory = test.load_and_deserialize(validator_history_address).await; + assert!(account.history.idx == 0); + assert!(account.history.arr[0].epoch == 1); + let (stake, rank, is_superminority) = stake_buffer_account + .get_by_validator_index(account.index) + .unwrap(); + let is_superminority = match is_superminority { + true => 1, + false => 0, + }; + assert!(account.history.arr[0].activated_stake_lamports == stake); + assert!(account.history.arr[0].is_superminority == is_superminority); + assert!(account.history.arr[0].rank == rank); + } + + // Advance epoch and try copying stake infos and assert they fail with stale buffer + test.advance_num_epochs(1).await; + + // Try copying stake info + for (_, validator_history_address) in validator_accounts.iter() { + // Build copy stake info instruction + let instruction = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::CopyStakeInfo { + validator_history_account: *validator_history_address, + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + } + .to_account_metas(None), + data: validator_history::instruction::CopyStakeInfo {}.data(), + }; + // Pack transaction + let hash = test.fresh_blockhash().await; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&test.keypair.pubkey()), + &[&test.keypair], + hash, + ); + test.submit_transaction_assert_error(transaction, "EpochOutOfRange") + .await; + } + + // Now insert into the buffer again with the new epoch and assert that only after the last + // insert does copy stake info succeed (buffer needs to be finalized) + for (i, (_vote_account_address, validator_history_address)) in + validator_accounts.iter().enumerate() + { + // Insert into buffer + let ix_data = validator_history::instruction::UpdateStakeBuffer {}; + let accounts = validator_history::accounts::UpdateStakeBuffer { + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + validator_history_account: *validator_history_address, + }; + let metas = accounts.to_account_metas(None); + let latest_blockhash = test.fresh_blockhash().await; + let transaction = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[ + solana_sdk::compute_budget::ComputeBudgetInstruction::set_compute_unit_limit( + 1_400_000, + ), + Instruction { + program_id: validator_history::id(), + accounts: metas, + data: ix_data.data(), + }, + ], + Some(&test.keypair.pubkey()), + &[&test.keypair], + latest_blockhash, + ); + test.submit_transaction_assert_success(transaction).await; + // Build copy stake info instruction + let instruction = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::CopyStakeInfo { + validator_history_account: *validator_history_address, + config: test.validator_history_config, + validator_stake_buffer_account: test.validator_stake_buffer_account, + } + .to_account_metas(None), + data: validator_history::instruction::CopyStakeInfo {}.data(), + }; + // Pack transaction + let hash = test.fresh_blockhash().await; + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&test.keypair.pubkey()), + &[&test.keypair], + hash, + ); + if i == num_validators - 1 { + // Last validator should succeed because buffer is finalized + test.submit_transaction_assert_success(transaction).await; + } else { + test.submit_transaction_assert_error(transaction, "StakeBufferNotFinalized") + .await; + } + } +} diff --git a/tests/tests/validator_history/test_state.rs b/tests/tests/validator_history/test_state.rs index b5f5eeb6..48e125ea 100644 --- a/tests/tests/validator_history/test_state.rs +++ b/tests/tests/validator_history/test_state.rs @@ -1,8 +1,14 @@ +use anchor_lang::error::Error; use validator_history::constants::TVC_MULTIPLIER; +use validator_history::errors::ValidatorHistoryError; use validator_history::state::CircBuf; -use validator_history::ValidatorHistoryEntry; +use validator_history::{ + constants::MAX_STAKE_BUFFER_VALIDATORS, Config, ValidatorHistoryEntry, ValidatorStake, + ValidatorStakeBuffer, +}; const MAX_ITEMS: usize = 512; + #[test] fn test_normalized_epoch_credits_latest() { let mut circ_buf = CircBuf { @@ -71,3 +77,411 @@ fn test_epoch_credits_range_normalized() { ] ); } + +#[test] +fn test_validator_stake_buffer_insert_empty_buffer() { + let mut buffer = ValidatorStakeBuffer::default(); + let entry = ValidatorStake::new(1, 100); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + buffer.insert(&config, entry).unwrap(); + assert_eq!(buffer.length(), 1); + assert_eq!(buffer.get(0).unwrap(), entry); +} + +#[test] +fn test_validator_stake_buffer_insert_partially_full_ordered() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + buffer.insert(&config, ValidatorStake::new(1, 100)).unwrap(); + buffer.insert(&config, ValidatorStake::new(2, 200)).unwrap(); + buffer.insert(&config, ValidatorStake::new(3, 300)).unwrap(); + assert_eq!(buffer.length(), 3); + assert_eq!(buffer.get(0).unwrap().stake_amount, 300); + assert_eq!(buffer.get(1).unwrap().stake_amount, 200); + assert_eq!(buffer.get(2).unwrap().stake_amount, 100); +} + +#[test] +fn test_validator_stake_buffer_insert_unordered_in_middle() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + buffer.insert(&config, ValidatorStake::new(1, 100)).unwrap(); + buffer.insert(&config, ValidatorStake::new(2, 300)).unwrap(); + buffer.insert(&config, ValidatorStake::new(3, 200)).unwrap(); + assert_eq!(buffer.length(), 3); + assert_eq!(buffer.get(0).unwrap().stake_amount, 300); + assert_eq!(buffer.get(1).unwrap().stake_amount, 200); + assert_eq!(buffer.get(2).unwrap().stake_amount, 100); +} + +#[test] +fn test_validator_stake_buffer_insert_unordered_at_start() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + buffer.insert(&config, ValidatorStake::new(1, 100)).unwrap(); + buffer.insert(&config, ValidatorStake::new(2, 300)).unwrap(); + buffer.insert(&config, ValidatorStake::new(3, 50)).unwrap(); + assert_eq!(buffer.length(), 3); + assert_eq!(buffer.get(0).unwrap().stake_amount, 300); + assert_eq!(buffer.get(1).unwrap().stake_amount, 100); + assert_eq!(buffer.get(2).unwrap().stake_amount, 50); +} + +#[test] +fn test_validator_stake_buffer_insert_partially_full_unordered() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + buffer.insert(&config, ValidatorStake::new(1, 300)).unwrap(); + buffer.insert(&config, ValidatorStake::new(2, 100)).unwrap(); + buffer.insert(&config, ValidatorStake::new(3, 200)).unwrap(); + assert_eq!(buffer.length(), 3); + assert_eq!(buffer.get(0).unwrap().stake_amount, 300); + assert_eq!(buffer.get(1).unwrap().stake_amount, 200); + assert_eq!(buffer.get(2).unwrap().stake_amount, 100); +} + +#[test] +fn test_validator_stake_buffer_finalized_error() { + let mut buffer = ValidatorStakeBuffer::default(); + let max_len = 10; + let config = Config { + counter: max_len, + ..Default::default() + }; + + // Fill the buffer to max_len + for i in 0..max_len { + buffer + .insert(&config, ValidatorStake::new(i, i as u64 + 100)) + .unwrap(); + } + assert_eq!(buffer.length(), max_len); + assert!(buffer.is_finalized()); + + // Attempt to insert into a full buffer + let new_entry = ValidatorStake::new(9999, 50); + let result = buffer.insert(&config, new_entry); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferFinalized) + ); + + // The buffer should remain unchanged in length and finalized state + assert_eq!(buffer.length(), max_len); + assert!(buffer.is_finalized()); + // Verify that the first element is the largest (descending sort) + assert_eq!( + buffer.get(0).unwrap().stake_amount, + 100 + (max_len as u64 - 1) + ); +} + +#[test] +fn test_validator_stake_buffer_finalized_with_monotonically_increasing_config() { + let mut buffer = ValidatorStakeBuffer::default(); + let initial_max_len = 5; + let initial_config = Config { + counter: initial_max_len, + ..Default::default() + }; + + // Fill the buffer to initial_max_len, which should finalize it + for i in 0..initial_max_len { + buffer + .insert(&initial_config, ValidatorStake::new(i, i as u64 + 100)) + .unwrap(); + } + assert_eq!(buffer.length(), initial_max_len); + assert!(buffer.is_finalized()); + + // Create a new config with a monotonically incremented counter + let new_max_len = initial_max_len + 5; + let new_config = Config { + counter: new_max_len, + ..Default::default() + }; + + // Attempt to insert into the same buffer using a new insert_builder with the new config + let new_entry = ValidatorStake::new(9999, 50); + let result = buffer.insert(&new_config, new_entry); + + // The insertion should still fail because the buffer was finalized with the previous config + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferFinalized) + ); + + // The buffer should remain unchanged in length and finalized state + assert_eq!(buffer.length(), initial_max_len); + assert!(buffer.is_finalized()); + // Verify that the first element is the largest (descending sort) + assert_eq!( + buffer.get(0).unwrap().stake_amount, + 100 + (initial_max_len as u64 - 1) + ); +} + +#[test] +fn test_stake_buffer_insert_duplicate_error() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + + // Insert an entry + let entry = ValidatorStake::new(1, 100); + buffer.insert(&config, entry).unwrap(); + assert_eq!(buffer.length(), 1); + + // Attempt to insert the same entry again + let result = buffer.insert(&config, entry); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferDuplicate) + ); + + // The buffer should remain unchanged in length + assert_eq!(buffer.length(), 1); + + // Attempt to insert a different entry with the same validator index + let new_entry_same_id = ValidatorStake::new(1, 200); + let result2 = buffer.insert(&config, new_entry_same_id); + assert!(result2.is_err()); + assert_eq!( + result2.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferDuplicate) + ); + + // The buffer should still be unchanged + assert_eq!(buffer.length(), 1); +} + +#[test] +fn test_stake_buffer_insert_until_finalized() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + + // Insert until finalized with max buffer length + for i in 0..MAX_STAKE_BUFFER_VALIDATORS { + // Insert an entry + let entry = ValidatorStake::new(i as u32, 100); + let res = buffer.insert(&config, entry); + assert!(res.is_ok()) + } + + // Try inserting again and assert err on is finalized + let entry = ValidatorStake::new(MAX_STAKE_BUFFER_VALIDATORS as u32 + 1, 100); + let res = buffer.insert(&config, entry); + assert_eq!( + res.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferFinalized) + ); + + // Build new buffer with counter less than max buffer length + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32 - 1, + ..Default::default() + }; + + // Insert until finalized with max buffer length + for i in 0..MAX_STAKE_BUFFER_VALIDATORS - 1 { + // Insert an entry + let entry = ValidatorStake::new(i as u32, 100); + let res = buffer.insert(&config, entry); + assert!(res.is_ok()) + } + + // Try inserting again and assert err on is finalized + let entry = ValidatorStake::new(MAX_STAKE_BUFFER_VALIDATORS as u32, 100); + let res = buffer.insert(&config, entry); + assert_eq!( + res.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferFinalized) + ); +} + +#[test] +fn test_get_by_validator_index_zero_total_stake() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + for i in 0..150 { + buffer.insert(&config, ValidatorStake::new(i, 0)).unwrap(); + } + assert_eq!(buffer.length(), 150); + assert_eq!(buffer.total_stake(), 0); + + let result = buffer.get_by_validator_index(0); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferEmpty) + ); + + let result = buffer.get_by_validator_index(75); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferEmpty) + ); +} + +#[test] +fn test_get_by_validator_index_superminority_calculation() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + // Total stake: 3 * 100 + 147 * 1 = 300 + 147 = 447 + // superminority_threshold_stake = 447 / 3 = 149 + // cumulative_stake_at_rank_0 = 100 + // cumulative_stake_at_rank_1 = 200 (so rank 1 is superminority threshold) + for i in 0..150 { + let stake = if i < 3 { 100 } else { 1 }; + buffer + .insert(&config, ValidatorStake::new(i, stake)) + .unwrap(); + } + assert_eq!(buffer.length(), 150); + + // Test validator at rank 0 (stake 100) + let (_, rank, is_superminority) = buffer.get_by_validator_index(0).unwrap(); + assert_eq!(rank, 0); + assert!(is_superminority); + + // Test validator at rank 1 (stake 100) + let (_, rank, is_superminority) = buffer.get_by_validator_index(1).unwrap(); + assert_eq!(rank, 1); + assert!(is_superminority); + + // Test validator outside superminority (rank 2, stake 100) + let (_, rank, is_superminority) = buffer.get_by_validator_index(2).unwrap(); + assert_eq!(rank, 2); + assert!(!is_superminority); + + // Test validator outside superminority (rank 3, stake 1) + let (_, rank, is_superminority) = buffer.get_by_validator_index(3).unwrap(); + assert_eq!(rank, 3); + assert!(!is_superminority); + + // Scenario 2: All validators have equal stake + // Total stake = 150 * 100 = 15000 + // Threshold = 15000 / 3 = 5000 + // cumulative_stake_at_rank_49 = 50 * 100 = 5000 + // cumulative_stake_at_rank_50 = 51 * 100 = 5100 (so rank 50 is threshold) + let mut buffer = ValidatorStakeBuffer::default(); + for i in 0..150 { + buffer.insert(&config, ValidatorStake::new(i, 100)).unwrap(); + } + + // Test validator at rank 49 + let (_, rank, is_superminority) = buffer.get_by_validator_index(49).unwrap(); + assert_eq!(rank, 49); + assert!(is_superminority); + + // Test validator at rank 50 + let (_, rank, is_superminority) = buffer.get_by_validator_index(50).unwrap(); + assert_eq!(rank, 50); + assert!(is_superminority); + + // Test validator at rank 51 + let (_, rank, is_superminority) = buffer.get_by_validator_index(51).unwrap(); + assert_eq!(rank, 51); + assert!(!is_superminority); +} + +#[test] +fn test_get_by_validator_index_basic_found_rank_and_stake() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + // Values are stake amount, validator index + // Rank 0: (1000, 0) + // Rank 1: (999, 1) + // ... + // Rank 100: (900, 100) + // + // Superminority cutoff is at rank 47 where cum stake is 46_872 + // Total stake is 138_832 ... 1/3 of that is 46_275 + for i in 0..150 { + buffer + .insert(&config, ValidatorStake::new(i, 1000 - i as u64)) + .unwrap(); + } + assert_eq!(buffer.length(), 150); + + // Test finding validator at rank 0 + let (stake, rank, is_superminority) = buffer.get_by_validator_index(0).unwrap(); + assert_eq!(stake, 1000); + assert_eq!(rank, 0); + assert!(is_superminority); + + // Test finding validator in the middle (at threshold) + let (stake, rank, is_superminority) = buffer.get_by_validator_index(47).unwrap(); + assert_eq!(stake, 1000 - 47); + assert_eq!(rank, 47); + assert!(is_superminority); + + // Test finding validator in the middle (after threshold) + let (stake, rank, is_superminority) = buffer.get_by_validator_index(48).unwrap(); + assert_eq!(stake, 1000 - 48); + assert_eq!(rank, 48); + assert!(!is_superminority); + + // Test finding validator at the end of inserted range (after threshold) + let (stake, rank, is_superminority) = buffer.get_by_validator_index(149).unwrap(); + assert_eq!(stake, 1000 - 149); + assert_eq!(rank, 149); + assert!(!is_superminority); +} + +#[test] +fn test_get_by_validator_index_validator_not_found() { + let mut buffer = ValidatorStakeBuffer::default(); + let config = Config { + counter: MAX_STAKE_BUFFER_VALIDATORS as u32, + ..Default::default() + }; + for i in 0..150 { + buffer + .insert(&config, ValidatorStake::new(i, 1000 - i as u64)) + .unwrap(); + } + assert_eq!(buffer.length(), 150); + + // Attempt to get a validator index that does not exist + let result = buffer.get_by_validator_index(9999); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::from(ValidatorHistoryError::StakeBufferOutOfBounds) + ); +}