diff --git a/.changelog/unreleased/features/1576-add-below-threshold-validators.md b/.changelog/unreleased/features/1576-add-below-threshold-validators.md new file mode 100644 index 0000000000..522e4a82e7 --- /dev/null +++ b/.changelog/unreleased/features/1576-add-below-threshold-validators.md @@ -0,0 +1,3 @@ +- Adds a third validator set, the below threshold set, which contains + all validators whose stake is below some parameterizable threshold. + ([#1576](https://github.com/anoma/namada/pull/1576)) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 26b227f745..b0a1615e2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3856,6 +3856,7 @@ dependencies = [ "once_cell", "proptest", "proptest-state-machine", + "rand 0.8.5", "rust_decimal", "rust_decimal_macros", "test-log", diff --git a/apps/src/lib/config/genesis.rs b/apps/src/lib/config/genesis.rs index 23cc402eae..ad3f5b2a24 100644 --- a/apps/src/lib/config/genesis.rs +++ b/apps/src/lib/config/genesis.rs @@ -138,22 +138,16 @@ pub mod genesis_config { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct GovernanceParamsConfig { // Min funds to stake to submit a proposal - // XXX: u64 doesn't work with toml-rs! pub min_proposal_fund: u64, // Maximum size of proposal in kibibytes (KiB) - // XXX: u64 doesn't work with toml-rs! pub max_proposal_code_size: u64, // Minimum proposal period length in epochs - // XXX: u64 doesn't work with toml-rs! pub min_proposal_period: u64, // Maximum proposal period length in epochs - // XXX: u64 doesn't work with toml-rs! pub max_proposal_period: u64, // Maximum number of characters in the proposal content - // XXX: u64 doesn't work with toml-rs! pub max_proposal_content_size: u64, // Minimum number of epoch between end and grace epoch - // XXX: u64 doesn't work with toml-rs! pub min_proposal_grace_epochs: u64, } @@ -180,10 +174,8 @@ pub mod genesis_config { // Validator address (default: generate). pub address: Option, // Total number of tokens held at genesis. - // XXX: u64 doesn't work with toml-rs! pub tokens: Option, // Unstaked balance at genesis. - // XXX: u64 doesn't work with toml-rs! pub non_staked_balance: Option, /// Commission rate charged on rewards for delegators (bounded inside /// 0-1) @@ -206,7 +198,6 @@ pub mod genesis_config { // Filename of token account VP. (default: token VP) pub vp: Option, // Initial balances held by accounts defined elsewhere. - // XXX: u64 doesn't work with toml-rs! pub balances: Option>, } @@ -244,7 +235,6 @@ pub mod genesis_config { /// serialization overhead in Tendermint blocks. pub max_proposal_bytes: ProposalBytes, /// Minimum number of blocks per epoch. - // XXX: u64 doesn't work with toml-rs! pub min_num_of_blocks: u64, /// Maximum duration per block (in seconds). // TODO: this is i64 because datetime wants it @@ -271,39 +261,33 @@ pub mod genesis_config { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct PosParamsConfig { // Maximum number of consensus validators. - // XXX: u64 doesn't work with toml-rs! pub max_validator_slots: u64, // Pipeline length (in epochs). - // XXX: u64 doesn't work with toml-rs! pub pipeline_len: u64, // Unbonding length (in epochs). - // XXX: u64 doesn't work with toml-rs! pub unbonding_len: u64, // Votes per token. - // XXX: u64 doesn't work with toml-rs! pub tm_votes_per_token: Decimal, // Reward for proposing a block. - // XXX: u64 doesn't work with toml-rs! pub block_proposer_reward: Decimal, // Reward for voting on a block. - // XXX: u64 doesn't work with toml-rs! pub block_vote_reward: Decimal, // Maximum staking APY - // XXX: u64 doesn't work with toml-rs! pub max_inflation_rate: Decimal, // Target ratio of staked NAM tokens to total NAM tokens pub target_staked_ratio: Decimal, // Portion of a validator's stake that should be slashed on a // duplicate vote. - // XXX: u64 doesn't work with toml-rs! pub duplicate_vote_min_slash_rate: Decimal, // Portion of a validator's stake that should be slashed on a // light client attack. - // XXX: u64 doesn't work with toml-rs! pub light_client_attack_min_slash_rate: Decimal, /// Number of epochs above and below (separately) the current epoch to /// consider when doing cubic slashing pub cubic_slashing_window_length: u64, + /// The minimum amount of bonded tokens that a validator needs to be in + /// either the `consensus` or `below_capacity` validator sets + pub validator_stake_threshold: token::Amount, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -647,6 +631,7 @@ pub mod genesis_config { duplicate_vote_min_slash_rate, light_client_attack_min_slash_rate, cubic_slashing_window_length, + validator_stake_threshold, } = pos_params; let pos_params = PosParams { max_validator_slots, @@ -660,6 +645,7 @@ pub mod genesis_config { duplicate_vote_min_slash_rate, light_client_attack_min_slash_rate, cubic_slashing_window_length, + validator_stake_threshold, }; let mut genesis = Genesis { diff --git a/core/src/types/token.rs b/core/src/types/token.rs index 68ba40552d..f2848d03a5 100644 --- a/core/src/types/token.rs +++ b/core/src/types/token.rs @@ -57,6 +57,11 @@ impl Amount { self.micro as Change } + /// Get the raw micro amount as a u64 value + pub fn raw_amount(&self) -> u64 { + self.micro + } + /// Spend a given amount. /// Panics when given `amount` > `self.micro` amount. pub fn spend(&mut self, amount: &Amount) { diff --git a/genesis/e2e-tests-single-node.toml b/genesis/e2e-tests-single-node.toml index f9860e6529..d77b4a626b 100644 --- a/genesis/e2e-tests-single-node.toml +++ b/genesis/e2e-tests-single-node.toml @@ -197,6 +197,9 @@ light_client_attack_min_slash_rate = 0.001 # Number of epochs above and below (separately) the current epoch to # consider when doing cubic slashing cubic_slashing_window_length = 1 +# The minimum amount of bonded tokens that a validator needs to be in +# either the `consensus` or `below_capacity` validator sets +validator_stake_threshold = "1" # Governance parameters. [gov_params] diff --git a/proof_of_stake/Cargo.toml b/proof_of_stake/Cargo.toml index 2e9bd22757..be1e2e9e73 100644 --- a/proof_of_stake/Cargo.toml +++ b/proof_of_stake/Cargo.toml @@ -27,6 +27,7 @@ data-encoding.workspace = true derivative.workspace = true once_cell.workspace = true proptest = {version = "1.2.0", optional = true} +rand = "0.8.5" rust_decimal_macros.workspace = true rust_decimal.workspace = true thiserror.workspace = true diff --git a/proof_of_stake/src/lib.rs b/proof_of_stake/src/lib.rs index 1308d92e11..5ac9353ff1 100644 --- a/proof_of_stake/src/lib.rs +++ b/proof_of_stake/src/lib.rs @@ -36,7 +36,7 @@ use namada_core::ledger::storage_api::collections::lazy_map::{ use namada_core::ledger::storage_api::collections::{LazyCollection, LazySet}; use namada_core::ledger::storage_api::token::credit_tokens; use namada_core::ledger::storage_api::{ - self, OptionExt, ResultExt, StorageRead, StorageWrite, + self, ResultExt, StorageRead, StorageWrite, }; use namada_core::types::address::{Address, InternalAddress}; use namada_core::types::key::{ @@ -695,6 +695,28 @@ where .collect() } +/// Read all addresses from the below-threshold set +pub fn read_below_threshold_validator_set_addresses( + storage: &S, + epoch: namada_core::types::storage::Epoch, +) -> storage_api::Result> +where + S: StorageRead, +{ + let params = read_pos_params(storage)?; + Ok(validator_addresses_handle() + .at(&epoch) + .iter(storage)? + .map(Result::unwrap) + .filter(|address| { + matches!( + validator_state_handle(address).get(storage, epoch, ¶ms), + Ok(Some(ValidatorState::BelowThreshold)) + ) + }) + .collect()) +} + /// Read all addresses from consensus validator set with their stake. pub fn read_consensus_validator_set_addresses_with_stake( storage: &S, @@ -987,13 +1009,20 @@ where S: StorageRead + StorageWrite, { let target_epoch = current_epoch + offset; - let consensus_set = &consensus_validator_set_handle().at(&target_epoch); - let below_cap_set = - &below_capacity_validator_set_handle().at(&target_epoch); + let consensus_set = consensus_validator_set_handle().at(&target_epoch); + let below_cap_set = below_capacity_validator_set_handle().at(&target_epoch); let num_consensus_validators = get_num_consensus_validators(storage, target_epoch)?; - if num_consensus_validators < params.max_validator_slots { + + if stake < params.validator_stake_threshold { + validator_state_handle(address).set( + storage, + ValidatorState::BelowThreshold, + current_epoch, + offset, + )?; + } else if num_consensus_validators < params.max_validator_slots { insert_validator_into_set( &consensus_set.at(&stake), storage, @@ -1010,7 +1039,7 @@ where // Check to see if the current genesis validator should replace one // already in the consensus set let min_consensus_amount = - get_min_consensus_validator_amount(consensus_set, storage)?; + get_min_consensus_validator_amount(&consensus_set, storage)?; if stake > min_consensus_amount { // Swap this genesis validator in and demote the last min consensus // validator @@ -1071,8 +1100,8 @@ where Ok(()) } -/// Update validator set when a validator receives a new bond and when -/// its bond is unbonded (self-bond or delegation). +/// Update validator set at the pipeline epoch when a validator receives a new +/// bond and when its bond is unbonded (self-bond or delegation). fn update_validator_set( storage: &mut S, params: &PosParams, @@ -1086,161 +1115,263 @@ where if token_change == 0_i128 { return Ok(()); } - let epoch = current_epoch + params.pipeline_len; + let pipeline_epoch = current_epoch + params.pipeline_len; tracing::debug!( - "Update epoch for validator set: {epoch}, validator: {validator}" + "Update epoch for validator set: {pipeline_epoch}, validator: \ + {validator}" ); let consensus_validator_set = consensus_validator_set_handle(); let below_capacity_validator_set = below_capacity_validator_set_handle(); - // Validator sets at the pipeline offset. If these are empty, then we need - // to copy over the most recent filled validator set into this epoch first - let consensus_val_handle = consensus_validator_set.at(&epoch); - let below_capacity_val_handle = below_capacity_validator_set.at(&epoch); + // Validator sets at the pipeline offset + let consensus_val_handle = consensus_validator_set.at(&pipeline_epoch); + let below_capacity_val_handle = + below_capacity_validator_set.at(&pipeline_epoch); - let tokens_pre = read_validator_stake(storage, params, validator, epoch)? - .unwrap_or_default(); + let tokens_pre = + read_validator_stake(storage, params, validator, pipeline_epoch)? + .unwrap_or_default(); // tracing::debug!("VALIDATOR STAKE BEFORE UPDATE: {}", tokens_pre); let tokens_post = tokens_pre.change() + token_change; - // TODO: handle overflow or negative vals perhaps with TryFrom let tokens_post = token::Amount::from_change(tokens_post); - // TODO: The position is only set when the validator is in consensus or - // below_capacity set (not in below_threshold set) - let position = - read_validator_set_position(storage, validator, epoch, params)? - .ok_or_err_msg( - "Validator must have a stored validator set position", - )?; - let consensus_vals_pre = consensus_val_handle.at(&tokens_pre); - - let in_consensus = if consensus_vals_pre.contains(storage, &position)? { - let val_address = consensus_vals_pre.get(storage, &position)?; - debug_assert!(val_address.is_some()); - val_address == Some(validator.clone()) - } else { - false - }; - - if in_consensus { - // It's initially consensus - tracing::debug!("Target validator is consensus"); + // If token amounts both before and after the action are below the threshold + // stake, do nothing + if tokens_pre < params.validator_stake_threshold + && tokens_post < params.validator_stake_threshold + { + return Ok(()); + } - consensus_vals_pre.remove(storage, &position)?; + // The position is only set when the validator is in consensus or + // below_capacity set (not in below_threshold set) + let position = read_validator_set_position( + storage, + validator, + pipeline_epoch, + params, + )?; + if let Some(position) = position { + let consensus_vals_pre = consensus_val_handle.at(&tokens_pre); - let max_below_capacity_validator_amount = - get_max_below_capacity_validator_amount( - &below_capacity_val_handle, - storage, - )? - .unwrap_or_default(); + let in_consensus = if consensus_vals_pre.contains(storage, &position)? { + let val_address = consensus_vals_pre.get(storage, &position)?; + debug_assert!(val_address.is_some()); + val_address == Some(validator.clone()) + } else { + false + }; - if tokens_post < max_below_capacity_validator_amount { - tracing::debug!("Need to swap validators"); - // Place the validator into the below-capacity set and promote the - // lowest position max below-capacity validator. + if in_consensus { + // It's initially consensus + tracing::debug!("Target validator is consensus"); - // Remove the max below-capacity validator first - let below_capacity_vals_max = below_capacity_val_handle - .at(&max_below_capacity_validator_amount.into()); - let lowest_position = - find_first_position(&below_capacity_vals_max, storage)? - .unwrap(); - let removed_max_below_capacity = below_capacity_vals_max - .remove(storage, &lowest_position)? - .expect("Must have been removed"); + // First remove the consensus validator + consensus_vals_pre.remove(storage, &position)?; - // Insert the previous max below-capacity validator into the - // consensus set - insert_validator_into_set( - &consensus_val_handle.at(&max_below_capacity_validator_amount), - storage, - &epoch, - &removed_max_below_capacity, - )?; - validator_state_handle(&removed_max_below_capacity).set( - storage, - ValidatorState::Consensus, - current_epoch, - params.pipeline_len, - )?; + let max_below_capacity_validator_amount = + get_max_below_capacity_validator_amount( + &below_capacity_val_handle, + storage, + )? + .unwrap_or_default(); - // Insert the current validator into the below-capacity set - insert_validator_into_set( - &below_capacity_val_handle.at(&tokens_post.into()), - storage, - &epoch, - validator, - )?; - validator_state_handle(validator).set( - storage, - ValidatorState::BelowCapacity, - current_epoch, - params.pipeline_len, - )?; + if tokens_post < params.validator_stake_threshold { + tracing::debug!( + "Demoting this validator to the below-threshold set" + ); + // Set the validator state as below-threshold + validator_state_handle(validator).set( + storage, + ValidatorState::BelowThreshold, + current_epoch, + params.pipeline_len, + )?; + + // Remove the validator's position from storage + validator_set_positions_handle() + .at(&pipeline_epoch) + .remove(storage, validator)?; + + // Promote the next below-cap validator if there is one + if let Some(max_bc_amount) = + get_max_below_capacity_validator_amount( + &below_capacity_val_handle, + storage, + )? + { + // Remove the max below-capacity validator first + let below_capacity_vals_max = + below_capacity_val_handle.at(&max_bc_amount.into()); + let lowest_position = + find_first_position(&below_capacity_vals_max, storage)? + .unwrap(); + let removed_max_below_capacity = below_capacity_vals_max + .remove(storage, &lowest_position)? + .expect("Must have been removed"); + + // Insert the previous max below-capacity validator into the + // consensus set + insert_validator_into_set( + &consensus_val_handle.at(&max_bc_amount), + storage, + &pipeline_epoch, + &removed_max_below_capacity, + )?; + validator_state_handle(&removed_max_below_capacity).set( + storage, + ValidatorState::Consensus, + current_epoch, + params.pipeline_len, + )?; + } + } else if tokens_post < max_below_capacity_validator_amount { + tracing::debug!( + "Demoting this validator to the below-capacity set and \ + promoting another to the consensus set" + ); + // Place the validator into the below-capacity set and promote + // the lowest position max below-capacity + // validator. + + // Remove the max below-capacity validator first + let below_capacity_vals_max = below_capacity_val_handle + .at(&max_below_capacity_validator_amount.into()); + let lowest_position = + find_first_position(&below_capacity_vals_max, storage)? + .unwrap(); + let removed_max_below_capacity = below_capacity_vals_max + .remove(storage, &lowest_position)? + .expect("Must have been removed"); + + // Insert the previous max below-capacity validator into the + // consensus set + insert_validator_into_set( + &consensus_val_handle + .at(&max_below_capacity_validator_amount), + storage, + &pipeline_epoch, + &removed_max_below_capacity, + )?; + validator_state_handle(&removed_max_below_capacity).set( + storage, + ValidatorState::Consensus, + current_epoch, + params.pipeline_len, + )?; + + // Insert the current validator into the below-capacity set + insert_validator_into_set( + &below_capacity_val_handle.at(&tokens_post.into()), + storage, + &pipeline_epoch, + validator, + )?; + validator_state_handle(validator).set( + storage, + ValidatorState::BelowCapacity, + current_epoch, + params.pipeline_len, + )?; + } else { + tracing::debug!("Validator remains in consensus set"); + // The current validator should remain in the consensus set - + // place it into a new position + insert_validator_into_set( + &consensus_val_handle.at(&tokens_post), + storage, + &pipeline_epoch, + validator, + )?; + } } else { - tracing::debug!("Validator remains in consensus set"); - // The current validator should remain in the consensus set - place - // it into a new position - insert_validator_into_set( - &consensus_val_handle.at(&tokens_post), - storage, - &epoch, - validator, - )?; - } - } else { - // TODO: handle the new third set - below threshold - - // It's initially below-capacity - let below_capacity_vals_pre = - below_capacity_val_handle.at(&tokens_pre.into()); - let removed = below_capacity_vals_pre.remove(storage, &position)?; - debug_assert!(removed.is_some()); - debug_assert_eq!(&removed.unwrap(), validator); - - let min_consensus_validator_amount = - get_min_consensus_validator_amount(&consensus_val_handle, storage)?; - - if tokens_post > min_consensus_validator_amount { - // Place the validator into the consensus set and demote the last - // position min consensus validator to the below-capacity set - - // Remove the min consensus validator first - let consensus_vals_min = - consensus_val_handle.at(&min_consensus_validator_amount); - let last_position_of_min_consensus_vals = - find_last_position(&consensus_vals_min, storage)?.expect( - "There must be always be at least 1 consensus validator", + // It's initially below-capacity + tracing::debug!("Target validator is below-capacity"); + + let below_capacity_vals_pre = + below_capacity_val_handle.at(&tokens_pre.into()); + let removed = below_capacity_vals_pre.remove(storage, &position)?; + debug_assert!(removed.is_some()); + debug_assert_eq!(&removed.unwrap(), validator); + + let min_consensus_validator_amount = + get_min_consensus_validator_amount( + &consensus_val_handle, + storage, + )?; + + if tokens_post > min_consensus_validator_amount { + // Place the validator into the consensus set and demote the + // last position min consensus validator to the + // below-capacity set + tracing::debug!( + "Inserting validator into the consensus set and demoting \ + a consensus validator to the below-capacity set" ); - let removed_min_consensus = consensus_vals_min - .remove(storage, &last_position_of_min_consensus_vals)? - .expect( - "There must be always be at least 1 consensus validator", + + insert_into_consensus_and_demote_to_below_cap( + storage, + params, + validator, + tokens_post, + min_consensus_validator_amount, + current_epoch, + &consensus_val_handle, + &below_capacity_val_handle, + )?; + } else if tokens_post >= params.validator_stake_threshold { + tracing::debug!("Validator remains in below-capacity set"); + // The current validator should remain in the below-capacity set + insert_validator_into_set( + &below_capacity_val_handle.at(&tokens_post.into()), + storage, + &pipeline_epoch, + validator, + )?; + validator_state_handle(validator).set( + storage, + ValidatorState::BelowCapacity, + current_epoch, + params.pipeline_len, + )?; + } else { + // The current validator is demoted to the below-threshold set + tracing::debug!( + "Demoting this validator to the below-threshold set" ); - // Insert the min consensus validator into the below-capacity set - insert_validator_into_set( - &below_capacity_val_handle - .at(&min_consensus_validator_amount.into()), - storage, - &epoch, - &removed_min_consensus, - )?; - validator_state_handle(&removed_min_consensus).set( - storage, - ValidatorState::BelowCapacity, - current_epoch, - params.pipeline_len, - )?; + validator_state_handle(validator).set( + storage, + ValidatorState::BelowThreshold, + current_epoch, + params.pipeline_len, + )?; + + // Remove the validator's position from storage + validator_set_positions_handle() + .at(&pipeline_epoch) + .remove(storage, validator)?; + } + } + } else { + // If there is no position at pipeline offset, then the validator must + // be in the below-threshold set + debug_assert!(tokens_pre < params.validator_stake_threshold); + tracing::debug!("Target validator is below-threshold"); + + // Move the validator into the appropriate set + let num_consensus_validators = + get_num_consensus_validators(storage, pipeline_epoch)?; + if num_consensus_validators < params.max_validator_slots { + // Just insert into the consensus set + tracing::debug!("Inserting validator into the consensus set"); - // Insert the current validator into the consensus set insert_validator_into_set( &consensus_val_handle.at(&tokens_post), storage, - &epoch, + &pipeline_epoch, validator, )?; validator_state_handle(validator).set( @@ -1250,21 +1381,107 @@ where params.pipeline_len, )?; } else { - // The current validator should remain in the below-capacity set - insert_validator_into_set( - &below_capacity_val_handle.at(&tokens_post.into()), - storage, - &epoch, - validator, - )?; - validator_state_handle(validator).set( - storage, - ValidatorState::BelowCapacity, - current_epoch, - params.pipeline_len, - )?; + let min_consensus_validator_amount = + get_min_consensus_validator_amount( + &consensus_val_handle, + storage, + )?; + if tokens_post > min_consensus_validator_amount { + // Insert this validator into consensus and demote one into the + // below-capacity + tracing::debug!( + "Inserting validator into the consensus set and demoting \ + a consensus validator to the below-capacity set" + ); + + insert_into_consensus_and_demote_to_below_cap( + storage, + params, + validator, + tokens_post, + min_consensus_validator_amount, + current_epoch, + &consensus_val_handle, + &below_capacity_val_handle, + )?; + } else { + // Insert this validator into below-capacity + tracing::debug!( + "Inserting validator into the below-capacity set" + ); + + insert_validator_into_set( + &below_capacity_val_handle.at(&tokens_post.into()), + storage, + &pipeline_epoch, + validator, + )?; + validator_state_handle(validator).set( + storage, + ValidatorState::BelowCapacity, + current_epoch, + params.pipeline_len, + )?; + } } } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn insert_into_consensus_and_demote_to_below_cap( + storage: &mut S, + params: &PosParams, + validator: &Address, + tokens_post: token::Amount, + min_consensus_amount: token::Amount, + current_epoch: Epoch, + consensus_set: &ConsensusValidatorSet, + below_capacity_set: &BelowCapacityValidatorSet, +) -> storage_api::Result<()> +where + S: StorageRead + StorageWrite, +{ + // First, remove the last position min consensus validator + let consensus_vals_min = consensus_set.at(&min_consensus_amount); + let last_position_of_min_consensus_vals = + find_last_position(&consensus_vals_min, storage)? + .expect("There must be always be at least 1 consensus validator"); + let removed_min_consensus = consensus_vals_min + .remove(storage, &last_position_of_min_consensus_vals)? + .expect("There must be always be at least 1 consensus validator"); + + let pipeline_epoch = current_epoch + params.pipeline_len; + + // Insert the min consensus validator into the below-capacity + // set + insert_validator_into_set( + &below_capacity_set.at(&min_consensus_amount.into()), + storage, + &pipeline_epoch, + &removed_min_consensus, + )?; + validator_state_handle(&removed_min_consensus).set( + storage, + ValidatorState::BelowCapacity, + current_epoch, + params.pipeline_len, + )?; + + // Insert the current validator into the consensus set + insert_validator_into_set( + &consensus_set.at(&tokens_post), + storage, + &pipeline_epoch, + validator, + )?; + validator_state_handle(validator).set( + storage, + ValidatorState::Consensus, + current_epoch, + params.pipeline_len, + )?; Ok(()) } @@ -1379,13 +1596,14 @@ fn read_validator_set_position( storage: &S, validator: &Address, epoch: Epoch, - params: &PosParams, + _params: &PosParams, ) -> storage_api::Result> where S: StorageRead, { let handle = validator_set_positions_handle(); - handle.get_position(storage, &epoch, validator, params) + // handle.get_position(storage, &epoch, validator, params) + handle.get_data_handler().at(&epoch).get(storage, validator) } /// Find the first (lowest) position in a validator set if it is not empty @@ -1842,16 +2060,15 @@ where params.pipeline_len, )?; - let stake = token::Amount::default(); - - insert_validator_into_validator_set( + // The validator's stake at initialization is 0, so its state is immediately + // below-threshold + validator_state_handle(address).set( storage, - params, - address, - stake, + ValidatorState::BelowThreshold, current_epoch, params.pipeline_len, )?; + Ok(()) } @@ -2155,53 +2372,55 @@ where // give Tendermint updates for the next epoch let next_epoch: Epoch = current_epoch.next(); - let cur_consensus_validators = + let new_consensus_validator_handle = consensus_validator_set_handle().at(&next_epoch); - let prev_consensus_validators = + let prev_consensus_validator_handle = consensus_validator_set_handle().at(¤t_epoch); - let consensus_validators = cur_consensus_validators + let new_consensus_validators = new_consensus_validator_handle .iter(storage)? .filter_map(|validator| { let ( NestedSubKey::Data { - key: cur_stake, + key: new_stake, nested_sub_key: _, }, address, ) = validator.unwrap(); tracing::debug!( - "Consensus validator address {address}, stake {cur_stake}" + "Consensus validator address {address}, stake {new_stake}" ); // Check if the validator was consensus in the previous epoch with - // the same stake + // the same stake. If so, no updated is needed. // Look up previous state and prev and current voting powers - if !prev_consensus_validators.is_empty(storage).unwrap() { + if !prev_consensus_validator_handle.is_empty(storage).unwrap() { let prev_state = validator_state_handle(&address) .get(storage, current_epoch, params) .unwrap(); let prev_tm_voting_power = Lazy::new(|| { - let prev_validator_stake = - validator_deltas_handle(&address) - .get_sum(storage, current_epoch, params) - .unwrap() - .map(token::Amount::from_change) - .unwrap_or_default(); + let prev_validator_stake = read_validator_stake( + storage, + params, + &address, + current_epoch, + ) + .unwrap() + .unwrap_or_default(); into_tm_voting_power( params.tm_votes_per_token, prev_validator_stake, ) }); - let cur_tm_voting_power = Lazy::new(|| { - into_tm_voting_power(params.tm_votes_per_token, cur_stake) + let new_tm_voting_power = Lazy::new(|| { + into_tm_voting_power(params.tm_votes_per_token, new_stake) }); // If it was in `Consensus` before and voting power has not // changed, skip the update if matches!(prev_state, Some(ValidatorState::Consensus)) - && *prev_tm_voting_power == *cur_tm_voting_power + && *prev_tm_voting_power == *new_tm_voting_power { tracing::debug!( "skipping validator update, {address} is in consensus \ @@ -2209,16 +2428,19 @@ where ); return None; } - - // If both previous and current voting powers are 0, skip - // update - if *prev_tm_voting_power == 0 && *cur_tm_voting_power == 0 { + // If both previous and current voting powers are 0, and the + // validator_stake_threshold is 0, skip update + if params.validator_stake_threshold == token::Amount::default() + && *prev_tm_voting_power == 0 + && *new_tm_voting_power == 0 + { tracing::info!( "skipping validator update, {address} is in consensus \ set but without voting power" ); return None; } + // TODO: maybe debug_assert that the new stake is >= threshold? } let consensus_key = validator_consensus_key_handle(&address) .get(storage, next_epoch, params) @@ -2230,69 +2452,60 @@ where ); Some(ValidatorSetUpdate::Consensus(ConsensusValidator { consensus_key, - bonded_stake: cur_stake.into(), + bonded_stake: new_stake.into(), })) }); - let cur_below_capacity_validators = - below_capacity_validator_set_handle().at(&next_epoch); - let prev_below_capacity_vals = - below_capacity_validator_set_handle().at(¤t_epoch); - - let below_capacity_validators = cur_below_capacity_validators - .iter(storage) - .unwrap() + + let prev_consensus_validators = prev_consensus_validator_handle + .iter(storage)? .filter_map(|validator| { let ( NestedSubKey::Data { - key: cur_stake, + key: _prev_stake, nested_sub_key: _, }, address, ) = validator.unwrap(); - let cur_stake = token::Amount::from(cur_stake); - tracing::debug!( - "Below-capacity validator address {address}, stake {cur_stake}" - ); + let new_state = validator_state_handle(&address) + .get(storage, next_epoch, params) + .unwrap(); - let prev_validator_stake = validator_deltas_handle(&address) - .get_sum(storage, current_epoch, params) + let prev_tm_voting_power = Lazy::new(|| { + let prev_validator_stake = read_validator_stake( + storage, + params, + &address, + current_epoch, + ) .unwrap() - .map(token::Amount::from_change) .unwrap_or_default(); - let prev_tm_voting_power = into_tm_voting_power( - params.tm_votes_per_token, - prev_validator_stake, - ); + into_tm_voting_power( + params.tm_votes_per_token, + prev_validator_stake, + ) + }); - // If the validator previously had no voting power, it wasn't in - // tendermint set and we have to skip it. - if prev_tm_voting_power == 0 { - tracing::debug!( - "skipping validator update {address}, it's inactive and \ - previously had no voting power" + // If the validator is still in the Consensus set, we accounted for + // it in the `new_consensus_validators` iterator above + if matches!(new_state, Some(ValidatorState::Consensus)) { + return None; + } else if params.validator_stake_threshold + == token::Amount::default() + && *prev_tm_voting_power == 0 + { + // If the new state is not Consensus but its prev voting power + // was 0 and the stake threshold is 0, we can also skip the + // update + tracing::info!( + "skipping validator update, {address} is in consensus set \ + but without voting power" ); return None; } - if !prev_below_capacity_vals.is_empty(storage).unwrap() { - // Look up the previous state - let prev_state = validator_state_handle(&address) - .get(storage, current_epoch, params) - .unwrap(); - // If the `prev_state.is_none()`, it's a new validator that - // is `BelowCapacity`, so no update is needed. If it - // previously was `BelowCapacity` there's no update needed - // either. - if !matches!(prev_state, Some(ValidatorState::Consensus)) { - tracing::debug!( - "skipping validator update, {address} is not and \ - wasn't previously in consensus set" - ); - return None; - } - } - + // The remaining validators were previously Consensus but no longer + // are, so they must be deactivated let consensus_key = validator_consensus_key_handle(&address) .get(storage, next_epoch, params) .unwrap() @@ -2303,8 +2516,9 @@ where ); Some(ValidatorSetUpdate::Deactivated(consensus_key)) }); - Ok(consensus_validators - .chain(below_capacity_validators) + + Ok(new_consensus_validators + .chain(prev_consensus_validators) .map(f) .collect()) } @@ -3092,6 +3306,9 @@ where .at(&token::Amount::from_change(amount_pre).into()) .remove(storage, &val_position)?; } + ValidatorState::BelowThreshold => { + println!("Below-threshold"); + } ValidatorState::Inactive => { println!("INACTIVE"); panic!( diff --git a/proof_of_stake/src/parameters.rs b/proof_of_stake/src/parameters.rs index 0e54e0f7f2..c2f4376872 100644 --- a/proof_of_stake/src/parameters.rs +++ b/proof_of_stake/src/parameters.rs @@ -2,6 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use namada_core::types::storage::Epoch; +use namada_core::types::token; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use rust_decimal_macros::dec; @@ -43,6 +44,9 @@ pub struct PosParams { /// Number of epochs above and below (separately) the current epoch to /// consider when doing cubic slashing pub cubic_slashing_window_length: u64, + /// The minimum amount of bonded tokens that a validator needs to be in + /// either the `consensus` or `below_capacity` validator sets + pub validator_stake_threshold: token::Amount, } impl Default for PosParams { @@ -65,6 +69,7 @@ impl Default for PosParams { // slash 0.1% light_client_attack_min_slash_rate: dec!(0.001), cubic_slashing_window_length: 1, + validator_stake_threshold: token::Amount::whole(1_u64), } } } diff --git a/proof_of_stake/src/tests.rs b/proof_of_stake/src/tests.rs index 95b8d91fac..4b6ac8f68b 100644 --- a/proof_of_stake/src/tests.rs +++ b/proof_of_stake/src/tests.rs @@ -42,6 +42,7 @@ use crate::{ get_num_consensus_validators, init_genesis, insert_validator_into_validator_set, is_validator, process_slashes, read_below_capacity_validator_set_addresses_with_stake, + read_below_threshold_validator_set_addresses, read_consensus_validator_set_addresses_with_stake, read_total_stake, read_validator_delta_value, read_validator_stake, slash, staking_token_address, total_deltas_handle, unbond_handle, unbond_tokens, @@ -60,9 +61,8 @@ proptest! { #[test] fn test_init_genesis( - pos_params in arb_pos_params(Some(5)), + (pos_params, genesis_validators) in arb_params_and_genesis_validators(Some(5), 1..10), start_epoch in (0_u64..1000).prop_map(Epoch), - genesis_validators in arb_genesis_validators(1..10), ) { test_init_genesis_aux(pos_params, start_epoch, genesis_validators) @@ -78,8 +78,7 @@ proptest! { #[test] fn test_bonds( - pos_params in arb_pos_params(Some(5)), - genesis_validators in arb_genesis_validators(1..3), + (pos_params, genesis_validators) in arb_params_and_genesis_validators(Some(5), 1..3), ) { test_bonds_aux(pos_params, genesis_validators) @@ -95,10 +94,9 @@ proptest! { #[test] fn test_become_validator( - pos_params in arb_pos_params(Some(5)), + (pos_params, genesis_validators) in arb_params_and_genesis_validators(Some(5), 1..3), new_validator in arb_established_address().prop_map(Address::Established), new_validator_consensus_key in arb_common_keypair(), - genesis_validators in arb_genesis_validators(1..3), ) { test_become_validator_aux(pos_params, new_validator, @@ -122,6 +120,20 @@ proptest! { } } +fn arb_params_and_genesis_validators( + num_max_validator_slots: Option, + val_size: Range, +) -> impl Strategy)> { + let params = arb_pos_params(num_max_validator_slots); + params.prop_flat_map(move |params| { + let validators = arb_genesis_validators( + val_size.clone(), + Some(params.validator_stake_threshold), + ); + (Just(params), validators) + }) +} + fn test_slashes_with_unbonding_params() -> impl Strategy, u64)> { let params = arb_pos_params(Some(5)); @@ -129,7 +141,7 @@ fn test_slashes_with_unbonding_params() let unbond_delay = 0..(params.slash_processing_epoch_offset() * 2); // Must have at least 4 validators so we can slash one and the cubic // slash rate will be less than 100% - let validators = arb_genesis_validators(4..10); + let validators = arb_genesis_validators(4..10, None); (Just(params), validators, unbond_delay) }) } @@ -177,7 +189,9 @@ fn test_init_genesis_aux( let state = validator_state_handle(&validator.address) .get(&s, start_epoch, ¶ms) .unwrap(); - if (i as u64) < params.max_validator_slots { + if (i as u64) < params.max_validator_slots + && validator.tokens >= params.validator_stake_threshold + { // should be in consensus set let handle = consensus_validator_set_handle().at(&start_epoch); assert!(handle.at(&validator.tokens).iter(&s).unwrap().any( @@ -187,10 +201,9 @@ fn test_init_genesis_aux( } )); assert_eq!(state, Some(ValidatorState::Consensus)); - } else { - // TODO: one more set once we have `below_threshold` - - // should be in below-capacity set + } else if validator.tokens >= params.validator_stake_threshold { + // Should be in below-capacity set if its tokens are greater than + // `validator_stake_threshold` let handle = below_capacity_validator_set_handle().at(&start_epoch); assert!(handle.at(&validator.tokens.into()).iter(&s).unwrap().any( |result| { @@ -199,6 +212,17 @@ fn test_init_genesis_aux( } )); assert_eq!(state, Some(ValidatorState::BelowCapacity)); + } else { + // Should be in below-threshold + let bt_addresses = + read_below_threshold_validator_set_addresses(&s, start_epoch) + .unwrap(); + assert!( + bt_addresses + .into_iter() + .any(|addr| { addr == validator.address }) + ); + assert_eq!(state, Some(ValidatorState::BelowThreshold)); } } } @@ -808,14 +832,9 @@ fn test_become_validator_aux( let num_consensus_after = get_num_consensus_validators(&s, current_epoch + params.pipeline_len) .unwrap(); - assert_eq!( - if validators.len() as u64 >= params.max_validator_slots { - num_consensus_before - } else { - num_consensus_before + 1 - }, - num_consensus_after - ); + // The new validator is initialized with no stake and thus is in the + // below-threshold set + assert_eq!(num_consensus_before, num_consensus_after); // Advance to epoch 2 current_epoch = advance_epoch(&mut s, ¶ms); @@ -1125,7 +1144,7 @@ fn test_validator_sets() { let ((val5, pk5), stake5) = (gen_validator(), token::Amount::whole(100)); let ((val6, pk6), stake6) = (gen_validator(), token::Amount::whole(1)); let ((val7, pk7), stake7) = (gen_validator(), token::Amount::whole(1)); - println!("val1: {val1}, {pk1}, {stake1}"); + println!("\nval1: {val1}, {pk1}, {stake1}"); println!("val2: {val2}, {pk2}, {stake2}"); println!("val3: {val3}, {pk3}, {stake3}"); println!("val4: {val4}, {pk4}, {stake4}"); @@ -1361,7 +1380,9 @@ fn test_validator_sets() { assert_eq!(tm_updates[1], ValidatorSetUpdate::Deactivated(pk2)); // Unbond some stake from val1, it should be be swapped with the greatest - // below-capacity validator val2 into the below-capacity set + // below-capacity validator val2 into the below-capacity set. The stake of + // val1 will go below 1 NAM, which is the validator_stake_threshold, so it + // will enter the below-threshold validator set. let unbond = token::Amount::from(500_000); let stake1 = stake1 - unbond; println!("val1 {val1} new stake {stake1}"); @@ -1422,7 +1443,7 @@ fn test_validator_sets() { .map(Result::unwrap) .collect(); - assert_eq!(below_capacity_vals.len(), 3); + assert_eq!(below_capacity_vals.len(), 2); assert!(matches!( &below_capacity_vals[0], (lazy_map::NestedSubKey::Data { @@ -1439,17 +1460,15 @@ fn test_validator_sets() { }, address) if address == &val6 && stake == &stake6 && *position == Position(2) )); - assert!(matches!( - &below_capacity_vals[2], - ( - lazy_map::NestedSubKey::Data { - key: ReverseOrdTokenAmount(stake), - nested_sub_key: lazy_map::SubKey::Data(position), - }, - address - ) - if address == &val1 && stake == &stake1 && *position == Position(0) - )); + + let below_threshold_vals = + read_below_threshold_validator_set_addresses(&s, pipeline_epoch) + .unwrap() + .into_iter() + .collect::>(); + + assert_eq!(below_threshold_vals.len(), 1); + assert_eq!(&below_threshold_vals[0], &val1); // Advance to EPOCH 5 let epoch = advance_epoch(&mut s, ¶ms); @@ -1461,7 +1480,7 @@ fn test_validator_sets() { assert!(tm_updates.is_empty()); // Insert another validator with stake 1 - it should be added to below - // capacity set after val1 + // capacity set insert_validator(&mut s, &val7, &pk7, stake7, epoch); // Epoch 7 let val7_epoch = pipeline_epoch; @@ -1506,7 +1525,7 @@ fn test_validator_sets() { .map(Result::unwrap) .collect(); - assert_eq!(below_capacity_vals.len(), 4); + assert_eq!(below_capacity_vals.len(), 3); assert!(matches!( &below_capacity_vals[0], (lazy_map::NestedSubKey::Data { @@ -1534,17 +1553,15 @@ fn test_validator_sets() { ) if address == &val7 && stake == &stake7 && *position == Position(3) )); - assert!(matches!( - &below_capacity_vals[3], - ( - lazy_map::NestedSubKey::Data { - key: ReverseOrdTokenAmount(stake), - nested_sub_key: lazy_map::SubKey::Data(position), - }, - address - ) - if address == &val1 && stake == &stake1 && *position == Position(0) - )); + + let below_threshold_vals = + read_below_threshold_validator_set_addresses(&s, pipeline_epoch) + .unwrap() + .into_iter() + .collect::>(); + + assert_eq!(below_threshold_vals.len(), 1); + assert_eq!(&below_threshold_vals[0], &val1); // Advance to EPOCH 6 let epoch = advance_epoch(&mut s, ¶ms); @@ -1620,7 +1637,7 @@ fn test_validator_sets() { .map(Result::unwrap) .collect(); - assert_eq!(below_capacity_vals.len(), 4); + assert_eq!(below_capacity_vals.len(), 3); dbg!(&below_capacity_vals); assert!(matches!( &below_capacity_vals[0], @@ -1649,17 +1666,15 @@ fn test_validator_sets() { ) if address == &val4 && stake == &stake4 && *position == Position(4) )); - assert!(matches!( - &below_capacity_vals[3], - ( - lazy_map::NestedSubKey::Data { - key: ReverseOrdTokenAmount(stake), - nested_sub_key: lazy_map::SubKey::Data(position), - }, - address - ) - if address == &val1 && stake == &stake1 && *position == Position(0) - )); + + let below_threshold_vals = + read_below_threshold_validator_set_addresses(&s, pipeline_epoch) + .unwrap() + .into_iter() + .collect::>(); + + assert_eq!(below_threshold_vals.len(), 1); + assert_eq!(&below_threshold_vals[0], &val1); // Advance to EPOCH 7 let epoch = advance_epoch(&mut s, ¶ms); @@ -1699,6 +1714,9 @@ fn test_validator_sets_swap() { // Only 2 consensus validator slots let params = PosParams { max_validator_slots: 2, + // Set the stake threshold to 0 so no validators are in the + // below-threshold set + validator_stake_threshold: token::Amount::default(), // Set 0.1 votes per token tm_votes_per_token: dec!(0.1), ..Default::default() @@ -1930,30 +1948,55 @@ fn advance_epoch(s: &mut TestWlStorage, params: &PosParams) -> Epoch { fn arb_genesis_validators( size: Range, + threshold: Option, ) -> impl Strategy> { let tokens: Vec<_> = (0..size.end) - .map(|_| (1..=10_000_000_u64).prop_map(token::Amount::from)) + .map(|ix| { + if ix == 0 { + // If there's a threshold, make sure that at least one validator + // has at least a stake greater or equal to the threshold to + // avoid having an empty consensus set. + threshold.map(|token| token.raw_amount()).unwrap_or(1) + ..=10_000_000_u64 + } else { + 1..=10_000_000_u64 + } + .prop_map(token::Amount::from) + }) .collect(); - (size, tokens).prop_map(|(size, token_amounts)| { - // use unique seeds to generate validators' address and consensus key - let seeds = (0_u64..).take(size); - seeds - .zip(token_amounts) - .map(|(seed, tokens)| { - let address = address_from_simple_seed(seed); - let consensus_sk = common_sk_from_simple_seed(seed); - let consensus_key = consensus_sk.to_public(); - - let commission_rate = Decimal::new(5, 2); - let max_commission_rate_change = Decimal::new(1, 2); - GenesisValidator { - address, - tokens, - consensus_key, - commission_rate, - max_commission_rate_change, + (size, tokens) + .prop_map(|(size, token_amounts)| { + // use unique seeds to generate validators' address and consensus + // key + let seeds = (0_u64..).take(size); + seeds + .zip(token_amounts) + .map(|(seed, tokens)| { + let address = address_from_simple_seed(seed); + let consensus_sk = common_sk_from_simple_seed(seed); + let consensus_key = consensus_sk.to_public(); + + let commission_rate = Decimal::new(5, 2); + let max_commission_rate_change = Decimal::new(1, 2); + GenesisValidator { + address, + tokens, + consensus_key, + commission_rate, + max_commission_rate_change, + } + }) + .collect() + }) + .prop_filter( + "Must have at least one genesis validator with stake above the \ + provided threshold, if any.", + move |gen_vals: &Vec| { + if let Some(thresh) = threshold { + gen_vals.iter().any(|val| val.tokens >= thresh) + } else { + true } - }) - .collect() - }) + }, + ) } diff --git a/proof_of_stake/src/tests/state_machine.rs b/proof_of_stake/src/tests/state_machine.rs index df3c85be13..89387b11ee 100644 --- a/proof_of_stake/src/tests/state_machine.rs +++ b/proof_of_stake/src/tests/state_machine.rs @@ -23,9 +23,9 @@ use rust_decimal_macros::dec; // `tracing` logs from tests use test_log::test; -use super::arb_genesis_validators; -use crate::parameters::testing::{arb_pos_params, arb_rate}; +use crate::parameters::testing::arb_rate; use crate::parameters::PosParams; +use crate::tests::arb_params_and_genesis_validators; use crate::types::{ decimal_mult_amount, decimal_mult_i128, BondId, GenesisValidator, ReverseOrdTokenAmount, Slash, SlashType, SlashedAmount, ValidatorState, @@ -33,8 +33,9 @@ use crate::types::{ }; use crate::{ below_capacity_validator_set_handle, consensus_validator_set_handle, - enqueued_slashes_handle, read_pos_params, validator_deltas_handle, - validator_slashes_handle, validator_state_handle, + enqueued_slashes_handle, read_below_threshold_validator_set_addresses, + read_pos_params, validator_deltas_handle, validator_slashes_handle, + validator_state_handle, }; prop_state_machine! { @@ -71,6 +72,8 @@ struct AbstractPosState { /// Below-capacity validator set. Pipelined. below_capacity_set: BTreeMap>>, + /// Below-threshold validator set. Pipelined. + below_threshold_set: BTreeMap>, /// Validator states. Pipelined. validator_states: BTreeMap>, /// Unbonded bonds. The outer key for Epoch is pipeline + unbonding offset @@ -652,26 +655,31 @@ impl ConcretePosState { ) { let pipeline = submit_epoch + params.pipeline_len; // Read the consensus sets data using iterator - let consensus_set = crate::consensus_validator_set_handle() + let num_in_consensus = crate::consensus_validator_set_handle() .at(&pipeline) .iter(&self.s) .unwrap() .map(|res| res.unwrap()) - .collect::>(); - let below_cap_set = crate::below_capacity_validator_set_handle() + .filter(|(_keys, addr)| addr == &id.validator) + .count(); + + let num_in_below_cap = crate::below_capacity_validator_set_handle() .at(&pipeline) .iter(&self.s) .unwrap() .map(|res| res.unwrap()) - .collect::>(); - let num_occurrences = consensus_set - .iter() .filter(|(_keys, addr)| addr == &id.validator) - .count() - + below_cap_set - .iter() - .filter(|(_keys, addr)| addr == &id.validator) + .count(); + + let num_in_below_thresh = + read_below_threshold_validator_set_addresses(&self.s, pipeline) + .unwrap() + .into_iter() + .filter(|addr| addr == &id.validator) .count(); + + let num_occurrences = + num_in_consensus + num_in_below_cap + num_in_below_thresh; let validator_is_jailed = crate::validator_state_handle(&id.validator) .get(&self.s, pipeline, params) .unwrap() @@ -696,22 +704,31 @@ impl ConcretePosState { &self.s, pipeline, ) .unwrap(); + let below_thresh_set = + crate::read_below_threshold_validator_set_addresses( + &self.s, pipeline, + ) + .unwrap(); let weighted = WeightedValidator { bonded_stake: stake_at_pipeline, address: id.validator, }; let consensus_val = consensus_set.get(&weighted); let below_cap_val = below_cap_set.get(&weighted); + let below_thresh_val = below_thresh_set.get(&weighted.address); // Post-condition: The validator should be updated in exactly once in // the validator sets let jailed_condition = validator_is_jailed && consensus_val.is_none() - && below_cap_val.is_none(); - assert!( - (consensus_val.is_some() ^ below_cap_val.is_some()) - || jailed_condition - ); + && below_cap_val.is_none() + && below_thresh_val.is_none(); + + let mut num_sets = i32::from(consensus_val.is_some()); + num_sets += i32::from(below_cap_val.is_some()); + num_sets += i32::from(below_thresh_val.is_some()); + + assert!(num_sets == 1 || jailed_condition); // Post-condition: The stake of the validators in the consensus set is // greater than or equal to below-capacity validators @@ -758,29 +775,36 @@ impl ConcretePosState { .unwrap() .contains(address) ); + assert!( + !crate::read_below_threshold_validator_set_addresses( + &self.s, epoch + ) + .unwrap() + .contains(address) + ); assert!( !crate::read_all_validator_addresses(&self.s, epoch) .unwrap() .contains(address) ); } - let weighted = WeightedValidator { - bonded_stake: Default::default(), - address: address.clone(), - }; let in_consensus = - crate::read_consensus_validator_set_addresses_with_stake( - &self.s, pipeline, - ) - .unwrap() - .contains(&weighted); - let in_bc = - crate::read_below_capacity_validator_set_addresses_with_stake( + crate::read_consensus_validator_set_addresses(&self.s, pipeline) + .unwrap() + .contains(address); + let in_bc = crate::read_below_capacity_validator_set_addresses( + &self.s, pipeline, + ) + .unwrap() + .contains(address); + let in_below_thresh = + crate::read_below_threshold_validator_set_addresses( &self.s, pipeline, ) .unwrap() - .contains(&weighted); - assert!(in_consensus ^ in_bc); + .contains(address); + + assert!(in_below_thresh && !in_consensus && !in_bc); } fn check_misbehavior_post_conditions( @@ -878,24 +902,35 @@ impl ConcretePosState { .unwrap(); assert_eq!(val_state, Some(ValidatorState::Jailed)); } - let in_consensus = consensus_validator_set_handle() - .at(&(current_epoch + params.pipeline_len)) + let pipeline_epoch = current_epoch + params.pipeline_len; + + let num_in_consensus = consensus_validator_set_handle() + .at(&pipeline_epoch) .iter(&self.s) .unwrap() - .any(|res| { - let (_, val_address) = res.unwrap(); - val_address == validator.clone() - }); + .map(|res| res.unwrap()) + .filter(|(_keys, addr)| addr == validator) + .count(); - let in_bc = below_capacity_validator_set_handle() - .at(&(current_epoch + params.pipeline_len)) + let num_in_bc = below_capacity_validator_set_handle() + .at(&pipeline_epoch) .iter(&self.s) .unwrap() - .any(|res| { - let (_, val_address) = res.unwrap(); - val_address == validator.clone() - }); - assert!(in_consensus ^ in_bc); + .map(|res| res.unwrap()) + .filter(|(_keys, addr)| addr == validator) + .count(); + + let num_in_bt = read_below_threshold_validator_set_addresses( + &self.s, + pipeline_epoch, + ) + .unwrap() + .into_iter() + .filter(|addr| addr == validator) + .count(); + + let num_occurrences = num_in_consensus + num_in_bc + num_in_bt; + assert_eq!(num_occurrences, 1); let val_state = validator_state_handle(validator) .get(&self.s, current_epoch + params.pipeline_len, params) @@ -903,6 +938,7 @@ impl ConcretePosState { assert!( val_state == Some(ValidatorState::Consensus) || val_state == Some(ValidatorState::BelowCapacity) + || val_state == Some(ValidatorState::BelowThreshold) ); } @@ -1039,6 +1075,53 @@ impl ConcretePosState { assert!(!vals.contains(&validator)); vals.insert(validator); } + + for validator in + crate::read_below_threshold_validator_set_addresses( + &self.s, epoch, + ) + .unwrap() + { + let stake = validator_deltas_handle(&validator) + .get_sum(&self.s, epoch, params) + .unwrap() + .unwrap_or_default(); + tracing::debug!( + "Below-thresh val {}, stake {}", + &validator, + stake + ); + + let state = crate::validator_state_handle(&validator) + .get(&self.s, epoch, params) + .unwrap() + .unwrap(); + + assert_eq!(state, ValidatorState::BelowThreshold); + assert_eq!( + state, + ref_state + .validator_states + .get(&epoch) + .unwrap() + .get(&validator) + .cloned() + .unwrap() + ); + assert_eq!( + stake, + ref_state + .validator_stakes + .get(&epoch) + .unwrap() + .get(&validator) + .cloned() + .unwrap() + ); + assert!(!vals.contains(&validator)); + vals.insert(validator); + } + // Jailed validators not in a set let all_validators = crate::read_all_validator_addresses(&self.s, epoch).unwrap(); @@ -1100,7 +1183,7 @@ impl ReferenceStateMachine for AbstractPosState { fn init_state() -> BoxedStrategy { println!("\nInitializing abstract state machine"); - (arb_pos_params(Some(5)), arb_genesis_validators(5..10)) + arb_params_and_genesis_validators(Some(5), 5..10) .prop_map(|(params, genesis_validators)| { let epoch = Epoch::default(); let mut state = Self { @@ -1118,6 +1201,7 @@ impl ReferenceStateMachine for AbstractPosState { validator_stakes: Default::default(), consensus_set: Default::default(), below_capacity_set: Default::default(), + below_threshold_set: Default::default(), validator_states: Default::default(), validator_slashes: Default::default(), enqueued_slashes: Default::default(), @@ -1153,7 +1237,19 @@ impl ReferenceStateMachine for AbstractPosState { .iter() .map(|(_stake, validators)| validators.len() as u64) .sum(); - let deque = if state.params.max_validator_slots + + if tokens < state.params.validator_stake_threshold { + state + .below_threshold_set + .entry(epoch) + .or_default() + .insert(address.clone()); + state + .validator_states + .entry(epoch) + .or_default() + .insert(address, ValidatorState::BelowThreshold); + } else if state.params.max_validator_slots > consensus_vals_len { state @@ -1161,7 +1257,10 @@ impl ReferenceStateMachine for AbstractPosState { .entry(epoch) .or_default() .insert(address.clone(), ValidatorState::Consensus); - consensus_set.entry(tokens).or_default() + consensus_set + .entry(tokens) + .or_default() + .push_back(address); } else { state .validator_states @@ -1176,11 +1275,13 @@ impl ReferenceStateMachine for AbstractPosState { below_cap_set .entry(ReverseOrdTokenAmount(tokens)) .or_default() + .push_back(address) }; - deque.push_back(address) } - // Ensure that below-capacity set is initialized even if empty + // Ensure that below-capacity and below-threshold sets are + // initialized even if empty state.below_capacity_set.entry(epoch).or_default(); + state.below_threshold_set.entry(epoch).or_default(); // Copy validator sets up to pipeline epoch for epoch in epoch.next().iter_range(state.params.pipeline_len) @@ -1335,37 +1436,18 @@ impl ReferenceStateMachine for AbstractPosState { .or_default() .insert(address.clone(), 0_i128); - // Insert into validator set at pipeline - let consensus_set = - state.consensus_set.entry(pipeline).or_default(); - - let consensus_vals_len = consensus_set - .iter() - .map(|(_stake, validators)| validators.len() as u64) - .sum(); - - let deque = if state.params.max_validator_slots - > consensus_vals_len - { - state - .validator_states - .entry(pipeline) - .or_default() - .insert(address.clone(), ValidatorState::Consensus); - consensus_set.entry(token::Amount::default()).or_default() - } else { - state - .validator_states - .entry(pipeline) - .or_default() - .insert(address.clone(), ValidatorState::BelowCapacity); - let below_cap_set = - state.below_capacity_set.entry(pipeline).or_default(); - below_cap_set - .entry(ReverseOrdTokenAmount(token::Amount::default())) - .or_default() - }; - deque.push_back(address.clone()); + // Insert into the below-threshold set at pipeline since the + // initial stake is 0 + state + .below_threshold_set + .entry(pipeline) + .or_default() + .insert(address.clone()); + state + .validator_states + .entry(pipeline) + .or_default() + .insert(address.clone(), ValidatorState::BelowThreshold); state.debug_validators(); } @@ -1555,6 +1637,15 @@ impl ReferenceStateMachine for AbstractPosState { .or_default() .remove(&stake.into()); } + } else if state + .is_in_below_threshold(address, current_epoch + offset) + { + let removed = state + .below_threshold_set + .entry(current_epoch + offset) + .or_default() + .remove(address); + debug_assert!(removed); } else { // Just make sure the validator is already jailed debug_assert_eq!( @@ -1622,7 +1713,20 @@ impl ReferenceStateMachine for AbstractPosState { sum + validators.len() as u64 }); - if num_consensus < state.params.max_validator_slots { + if pipeline_stake + < state.params.validator_stake_threshold.change() + { + // Place into the below-threshold set + let below_threshold_set_pipeline = state + .below_threshold_set + .entry(pipeline_epoch) + .or_default(); + below_threshold_set_pipeline.insert(address.clone()); + validator_states_pipeline.insert( + address.clone(), + ValidatorState::BelowThreshold, + ); + } else if num_consensus < state.params.max_validator_slots { // Place directly into the consensus set debug_assert!( state @@ -1806,7 +1910,8 @@ impl ReferenceStateMachine for AbstractPosState { .iter() .filter(|(_addr, val_state)| match val_state { ValidatorState::Consensus - | ValidatorState::BelowCapacity => true, + | ValidatorState::BelowCapacity + | ValidatorState::BelowThreshold => true, ValidatorState::Inactive | ValidatorState::Jailed => false, }) @@ -1906,6 +2011,10 @@ impl AbstractPosState { epoch, self.below_capacity_set.get(&prev_epoch).unwrap().clone(), ); + self.below_threshold_set.insert( + epoch, + self.below_threshold_set.get(&prev_epoch).unwrap().clone(), + ); self.validator_states.insert( epoch, self.validator_states.get(&prev_epoch).unwrap().clone(), @@ -2065,20 +2174,29 @@ impl AbstractPosState { let consensus_set = self.consensus_set.entry(pipeline).or_default(); let below_cap_set = self.below_capacity_set.entry(pipeline).or_default(); + let below_thresh_set = + self.below_threshold_set.entry(pipeline).or_default(); + let validator_stakes = self.validator_stakes.get(&pipeline).unwrap(); let validator_states = self.validator_states.get_mut(&pipeline).unwrap(); - let state = validator_states.get(validator).unwrap(); + let state_pre = validator_states.get(validator).unwrap(); let this_val_stake_pre = *validator_stakes.get(validator).unwrap(); let this_val_stake_post = token::Amount::from_change(this_val_stake_pre + change); - let this_val_stake_pre = token::Amount::from_change( - *validator_stakes.get(validator).unwrap(), - ); + let this_val_stake_pre = token::Amount::from_change(this_val_stake_pre); + + let threshold = self.params.validator_stake_threshold; + if this_val_stake_pre < threshold && this_val_stake_post < threshold { + // Validator is already below-threshold and will remain there, so do + // nothing + debug_assert!(below_thresh_set.contains(validator)); + return; + } - match state { + match state_pre { ValidatorState::Consensus => { // println!("Validator initially in consensus"); // Remove from the prior stake @@ -2091,6 +2209,37 @@ impl AbstractPosState { consensus_set.remove(&this_val_stake_pre); } + // If posterior stake is below threshold, place into the + // below-threshold set + if this_val_stake_post < threshold { + below_thresh_set.insert(validator.clone()); + validator_states.insert( + validator.clone(), + ValidatorState::BelowThreshold, + ); + + // Promote the next below-cap validator if there is one + if let Some(mut max_below_cap) = below_cap_set.last_entry() + { + let max_below_cap_stake = *max_below_cap.key(); + let vals = max_below_cap.get_mut(); + let promoted_val = vals.pop_front().unwrap(); + // Remove the key if there's nothing left + if vals.is_empty() { + below_cap_set.remove(&max_below_cap_stake); + } + + consensus_set + .entry(max_below_cap_stake.0) + .or_default() + .push_back(promoted_val.clone()); + validator_states + .insert(promoted_val, ValidatorState::Consensus); + } + + return; + } + // If unbonding, check the max below-cap validator's state if we // need to do a swap if change < token::Change::default() { @@ -2146,6 +2295,17 @@ impl AbstractPosState { below_cap_set.remove(&this_val_stake_pre.into()); } + // If posterior stake is below threshold, place into the + // below-threshold set + if this_val_stake_post < threshold { + below_thresh_set.insert(validator.clone()); + validator_states.insert( + validator.clone(), + ValidatorState::BelowThreshold, + ); + return; + } + // If bonding, check the min consensus validator's state if we // need to do a swap if change >= token::Change::default() { @@ -2194,6 +2354,67 @@ impl AbstractPosState { .or_default() .push_back(validator.clone()); } + ValidatorState::BelowThreshold => { + // We know that this validator will be promoted into one of the + // higher sets, so first remove from the below-threshold set. + below_thresh_set.remove(validator); + + let num_consensus = + consensus_set.iter().fold(0, |sum, (_, validators)| { + sum + validators.len() as u64 + }); + if num_consensus < self.params.max_validator_slots { + // Place the validator directly into the consensus set + consensus_set + .entry(this_val_stake_post) + .or_default() + .push_back(validator.clone()); + validator_states + .insert(validator.clone(), ValidatorState::Consensus); + return; + } + // Determine which set to place the validator into + if let Some(mut min_consensus) = consensus_set.first_entry() { + // dbg!(&min_consensus); + let min_consensus_stake = *min_consensus.key(); + if this_val_stake_post > min_consensus_stake { + // Swap this validator with the max consensus + let vals = min_consensus.get_mut(); + let last_val = vals.pop_back().unwrap(); + // Remove the key if there's nothing left + if vals.is_empty() { + consensus_set.remove(&min_consensus_stake); + } + // Do the swap in the validator sets + below_cap_set + .entry(min_consensus_stake.into()) + .or_default() + .push_back(last_val.clone()); + consensus_set + .entry(this_val_stake_post) + .or_default() + .push_back(validator.clone()); + + // Change the validator states + validator_states.insert( + validator.clone(), + ValidatorState::Consensus, + ); + validator_states + .insert(last_val, ValidatorState::BelowCapacity); + } else { + // Place the validator into the below-capacity set + below_cap_set + .entry(this_val_stake_post.into()) + .or_default() + .push_back(validator.clone()); + validator_states.insert( + validator.clone(), + ValidatorState::BelowCapacity, + ); + } + } + } ValidatorState::Inactive => { panic!("unexpected state") } @@ -2544,6 +2765,14 @@ impl AbstractPosState { None } + fn is_in_below_threshold(&self, validator: &Address, epoch: Epoch) -> bool { + self.below_threshold_set + .get(&epoch) + .unwrap() + .iter() + .any(|val| val == validator) + } + /// Find the sums of the bonds across all epochs fn bond_sums(&self) -> BTreeMap { self.bonds.iter().fold( @@ -2714,8 +2943,39 @@ impl AbstractPosState { debug_assert_eq!(*val_state, ValidatorState::BelowCapacity); } } + if max_bc > min_consensus { + println!( + "min_consensus = {}, max_bc = {}", + min_consensus, max_bc + ); + } assert!(min_consensus >= max_bc); + for addr in self.below_threshold_set.get(&epoch).unwrap() { + let state = self + .validator_states + .get(&epoch) + .unwrap() + .get(addr) + .unwrap(); + + let stake = self + .validator_stakes + .get(&epoch) + .unwrap() + .get(addr) + .cloned() + .unwrap_or_default(); + tracing::debug!( + "Below-thresh val {}, stake {} - ({:?})", + addr, + &stake, + state + ); + + assert_eq!(*state, ValidatorState::BelowThreshold); + } + for addr in self .validator_states .get(&epoch) @@ -2724,9 +2984,10 @@ impl AbstractPosState { .cloned() .collect::>() { - if let (None, None) = ( + if let (None, None, false) = ( self.is_in_consensus_w_info(&addr, epoch), self.is_in_below_capacity_w_info(&addr, epoch), + self.is_in_below_threshold(&addr, epoch), ) { assert_eq!( self.validator_states diff --git a/proof_of_stake/src/types.rs b/proof_of_stake/src/types.rs index bf81ad2286..dffac10101 100644 --- a/proof_of_stake/src/types.rs +++ b/proof_of_stake/src/types.rs @@ -362,6 +362,9 @@ pub enum ValidatorState { /// A validator who does not have enough stake to be considered in the /// `Consensus` validator set but still may have active bonds and unbonds BelowCapacity, + /// A validator who has stake less than the `validator_stake_threshold` + /// parameter + BelowThreshold, /// A validator who is deactivated via a tx when a validator no longer /// wants to be one (not implemented yet) Inactive, diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index c8dc216c0d..5bf9ec1358 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -3165,6 +3165,7 @@ dependencies = [ "namada_core", "once_cell", "proptest", + "rand 0.8.5", "rust_decimal", "rust_decimal_macros", "thiserror", diff --git a/wasm/checksums.json b/wasm/checksums.json index e05666df6e..dfab434e58 100644 --- a/wasm/checksums.json +++ b/wasm/checksums.json @@ -1,21 +1,21 @@ { - "tx_bond.wasm": "tx_bond.9b8df8657be80f070485e15199dcbf6b920c5b8929c95846788a7277322ac0b8.wasm", - "tx_change_validator_commission.wasm": "tx_change_validator_commission.cab8564fb682e6acf8e2d9140e9b8dac2872b6525ccece45e24b274070819833.wasm", - "tx_ibc.wasm": "tx_ibc.1f9962f14fc04e44e2459795216579b985c66b547f40df6e1d0d6117a04ce058.wasm", - "tx_init_account.wasm": "tx_init_account.a5082699b37e9704302af3ff5e6f67d5f02ee97d4369c98309d6e616bd57b779.wasm", - "tx_init_proposal.wasm": "tx_init_proposal.b997fb0a7a5369437cb5a37b74684ddc4eff5c451566f3d0538a717964481302.wasm", - "tx_init_validator.wasm": "tx_init_validator.c6470ec72d09a0841f818e356081e9231613b9375a677ad34431f31246ec725f.wasm", + "tx_bond.wasm": "tx_bond.1da1a45aa652cc1e622d2378c2ef5ecc016d9d2e2aed9ca04a5ca33494d8ffbd.wasm", + "tx_change_validator_commission.wasm": "tx_change_validator_commission.cfe99d831dce4d1e02e438d863db5a0a0c855b59605b48d9a1b3e0da93c4be4f.wasm", + "tx_ibc.wasm": "tx_ibc.cdeeb8017880588b1cc45fcd4e4176c6fa89eb77601404bafa6f5c4103886e3a.wasm", + "tx_init_account.wasm": "tx_init_account.5d9b02ec449730163528dfa002ac5567c6ed56cd020bf723d265d7553aeaac90.wasm", + "tx_init_proposal.wasm": "tx_init_proposal.6bb0119495d79985dba89c0eab4d5d4c626ff4dd5e80890401dab79ac3dc89a1.wasm", + "tx_init_validator.wasm": "tx_init_validator.280d799cba7e795a0f5c8329ff03b9377c501730e09e518d62421729c0efff88.wasm", "tx_reveal_pk.wasm": "tx_reveal_pk.ffdc2241c8fac28e52d33961b4c6813dfec1ae9ffafdd0b7d8f307c3093dfeb8.wasm", - "tx_transfer.wasm": "tx_transfer.b9356405dd27fe0405f32e0bc86d3aee3d228fbcbe7108f9b71128d52fbd67ea.wasm", - "tx_unbond.wasm": "tx_unbond.45aee4615d36c6553e613dfdca756ae2856993b24c8f46a2fe274db2ae1da07f.wasm", - "tx_unjail_validator.wasm": "tx_unjail_validator.df38aab0c05fe42b8036dc72c9b9fa4563116f62030b66f5080918c2994f723a.wasm", - "tx_update_vp.wasm": "tx_update_vp.54981ff2f04596ddcc69894f75f51c0ecd3b000c74d35ec91f8899a1dc2dbb72.wasm", - "tx_vote_proposal.wasm": "tx_vote_proposal.ab17eedfa0ee7db45300732d1d31cb4db81322a69824f936a07739f2c43115e2.wasm", - "tx_withdraw.wasm": "tx_withdraw.47bb8bef9719b4367d6dfbcf64e07af9bb97df752b249afd67b248dbff65c152.wasm", - "vp_implicit.wasm": "vp_implicit.70e268b7367c9d1b1c6adc4ed216989eedee257cbdf4e8dc931a4430126f3ef3.wasm", - "vp_masp.wasm": "vp_masp.f80362039d989503d4d2eccf070a8cb3feec775c804567b78b0619cf173f1b5f.wasm", - "vp_testnet_faucet.wasm": "vp_testnet_faucet.45f611a0bb2b437246c612710c88e1cf79fc2e8520078e410bd08b139d62514f.wasm", - "vp_token.wasm": "vp_token.d9f64fd1d645ee50200bad803fe288a43123eda601894f69eead6dfa4703c915.wasm", - "vp_user.wasm": "vp_user.8f293808d127ffbb14619ff28775a902d87d672bfd6fb49a59b55acfc43beaa8.wasm", - "vp_validator.wasm": "vp_validator.348d5b9a088781d0dc4c02e3541add718f2fa2fd6e221b47c8130cd50ec68637.wasm" + "tx_transfer.wasm": "tx_transfer.b0a2535242d732ce7e1c4f7c7b33098249f205937066fff1ef4158c7ce0b93c5.wasm", + "tx_unbond.wasm": "tx_unbond.f9506a73f41d9e0b33e6c371aa421f4463e6e770672b4c69cec66f29278e3a70.wasm", + "tx_unjail_validator.wasm": "tx_unjail_validator.19b9d7b7ae0b9e7145355701287701de6cbdf805bdb55d622d117deefd941476.wasm", + "tx_update_vp.wasm": "tx_update_vp.255d43a521f910d69ecffe82d5043c81d3b393a1cbebe6283ce25f160cf69395.wasm", + "tx_vote_proposal.wasm": "tx_vote_proposal.47103763f7f95ff068edf7f51ad4f538cc70ba5977d011aa6622df3b41c731ca.wasm", + "tx_withdraw.wasm": "tx_withdraw.2996c2ff3d9262c73598a9a97d1fe593773da32d3d853c7ea189634f117c43b2.wasm", + "vp_implicit.wasm": "vp_implicit.68fabea4aad151118598eb5a7773e2fc2902778ed1d4c58131cdecaea15f4e25.wasm", + "vp_masp.wasm": "vp_masp.398747470a8e95ad6b10fb8a7df301323af306f4ef33a04f2303ce2e1c312975.wasm", + "vp_testnet_faucet.wasm": "vp_testnet_faucet.ed4ebd0b1dd32e917b15a0467cdc68215382a65d590ec7787c1ff76f59e567a8.wasm", + "vp_token.wasm": "vp_token.79e6b7848bd62ac5be62d17cc7f2df19e54fa8d0f2bcdb4cceabdf781f6782b1.wasm", + "vp_user.wasm": "vp_user.5ee74847e2a39343d09cd8c85d41037f9a4cc4cd3ff3b1ccc5a3100cd3106970.wasm", + "vp_validator.wasm": "vp_validator.cae21fb91b02ec0ba3b0237eccb42b9722011677db4ec59e4fbdcc8234d2a7a0.wasm" } \ No newline at end of file diff --git a/wasm/wasm_source/src/tx_bond.rs b/wasm/wasm_source/src/tx_bond.rs index 35e8ba7fac..eb5e44e58d 100644 --- a/wasm/wasm_source/src/tx_bond.rs +++ b/wasm/wasm_source/src/tx_bond.rs @@ -67,6 +67,12 @@ mod tests { key: key::common::SecretKey, pos_params: PosParams, ) -> TxResult { + // Remove the validator stake threshold for simplicity + let pos_params = PosParams { + validator_stake_threshold: token::Amount::default(), + ..pos_params + }; + dbg!(&initial_stake, &bond); let is_delegation = matches!(&bond.source, Some(source) if *source != bond.validator); diff --git a/wasm/wasm_source/src/tx_unbond.rs b/wasm/wasm_source/src/tx_unbond.rs index 5b4c5f3859..1ca4114b51 100644 --- a/wasm/wasm_source/src/tx_unbond.rs +++ b/wasm/wasm_source/src/tx_unbond.rs @@ -64,6 +64,12 @@ mod tests { key: key::common::SecretKey, pos_params: PosParams, ) -> TxResult { + // Remove the validator stake threshold for simplicity + let pos_params = PosParams { + validator_stake_threshold: token::Amount::default(), + ..pos_params + }; + dbg!(&initial_stake, &unbond); let is_delegation = matches!( &unbond.source, Some(source) if *source != unbond.validator); diff --git a/wasm/wasm_source/src/tx_withdraw.rs b/wasm/wasm_source/src/tx_withdraw.rs index 267f180c94..51d90e7957 100644 --- a/wasm/wasm_source/src/tx_withdraw.rs +++ b/wasm/wasm_source/src/tx_withdraw.rs @@ -69,6 +69,12 @@ mod tests { key: key::common::SecretKey, pos_params: PosParams, ) -> TxResult { + // Remove the validator stake threshold for simplicity + let pos_params = PosParams { + validator_stake_threshold: token::Amount::default(), + ..pos_params + }; + let is_delegation = matches!( &withdraw.source, Some(source) if *source != withdraw.validator); let consensus_key = key::testing::keypair_1().ref_to(); diff --git a/wasm_for_tests/wasm_source/Cargo.lock b/wasm_for_tests/wasm_source/Cargo.lock index 3475d1971b..35f8ca7cb0 100644 --- a/wasm_for_tests/wasm_source/Cargo.lock +++ b/wasm_for_tests/wasm_source/Cargo.lock @@ -3165,6 +3165,7 @@ dependencies = [ "namada_core", "once_cell", "proptest", + "rand 0.8.5", "rust_decimal", "rust_decimal_macros", "thiserror",