diff --git a/rs/nns/governance/proto/ic_nns_governance/pb/v1/governance.proto b/rs/nns/governance/proto/ic_nns_governance/pb/v1/governance.proto index 525d065abd7a..52137de41896 100644 --- a/rs/nns/governance/proto/ic_nns_governance/pb/v1/governance.proto +++ b/rs/nns/governance/proto/ic_nns_governance/pb/v1/governance.proto @@ -1979,6 +1979,16 @@ message LoadCanisterSnapshot { bytes snapshot_id = 2; } +// Snapshot of a neuron's dissolve state, recorded before clamping to the +// Mission 70 maximum. Used to restore the original dissolve state if the +// maximum dissolve delay is ever increased again. +message NeuronDissolveStateSnapshot { + oneof dissolve_state { + uint64 dissolve_delay_seconds = 1; + uint64 when_dissolved_timestamp_seconds = 2; + } +} + // This represents the whole NNS governance system. It contains all // information about the NNS governance system that must be kept // across upgrades of the NNS governance system. @@ -2219,6 +2229,10 @@ message Governance { // This prevents the migration from running more than once. bool eight_year_gang_bonus_migration_done = 31; + // Snapshot of each neuron's dissolve state taken while clamping to the Mission 70 maximum + // dissolve delay. Enables restoring original dissolve states if the maximum is reversed. + map neuron_id_to_pre_clamp_dissolve_state = 32; + reserved 30; reserved "first_proposal_id_to_record_voting_history"; diff --git a/rs/nns/governance/src/gen/ic_nns_governance.pb.v1.rs b/rs/nns/governance/src/gen/ic_nns_governance.pb.v1.rs index b65326c95637..12fd90e528d1 100644 --- a/rs/nns/governance/src/gen/ic_nns_governance.pb.v1.rs +++ b/rs/nns/governance/src/gen/ic_nns_governance.pb.v1.rs @@ -2955,6 +2955,42 @@ pub struct LoadCanisterSnapshot { #[prost(bytes = "vec", tag = "2")] pub snapshot_id: ::prost::alloc::vec::Vec, } +/// Snapshot of a neuron's dissolve state, recorded before clamping to the +/// Mission 70 maximum. Used to restore the original dissolve state if the +/// maximum dissolve delay is ever increased again. +#[derive( + candid::CandidType, + candid::Deserialize, + serde::Serialize, + comparable::Comparable, + Clone, + Copy, + PartialEq, + ::prost::Message, +)] +pub struct NeuronDissolveStateSnapshot { + #[prost(oneof = "neuron_dissolve_state_snapshot::DissolveState", tags = "1, 2")] + pub dissolve_state: ::core::option::Option, +} +/// Nested message and enum types in `NeuronDissolveStateSnapshot`. +pub mod neuron_dissolve_state_snapshot { + #[derive( + candid::CandidType, + candid::Deserialize, + serde::Serialize, + comparable::Comparable, + Clone, + Copy, + PartialEq, + ::prost::Oneof, + )] + pub enum DissolveState { + #[prost(uint64, tag = "1")] + DissolveDelaySeconds(u64), + #[prost(uint64, tag = "2")] + WhenDissolvedTimestampSeconds(u64), + } +} /// This represents the whole NNS governance system. It contains all /// information about the NNS governance system that must be kept /// across upgrades of the NNS governance system. @@ -3078,6 +3114,11 @@ pub struct Governance { /// This prevents the migration from running more than once. #[prost(bool, tag = "31")] pub eight_year_gang_bonus_migration_done: bool, + /// Snapshot of each neuron's dissolve state taken while clamping to the Mission 70 maximum + /// dissolve delay. Enables restoring original dissolve states if the maximum is reversed. + #[prost(map = "uint64, message", tag = "32")] + pub neuron_id_to_pre_clamp_dissolve_state: + ::std::collections::HashMap, } /// Nested message and enum types in `Governance`. pub mod governance { diff --git a/rs/nns/governance/src/governance.rs b/rs/nns/governance/src/governance.rs index 6a580d685e11..41e8c09fb468 100644 --- a/rs/nns/governance/src/governance.rs +++ b/rs/nns/governance/src/governance.rs @@ -13,7 +13,8 @@ use crate::{ HeapGovernanceData, XdrConversionRate, initialize_governance, reassemble_governance_proto, split_governance_proto, }, - is_comprehensive_neuron_list_enabled, is_neuron_follow_restrictions_enabled, + is_comprehensive_neuron_list_enabled, is_mission_70_voting_rewards_enabled, + is_neuron_follow_restrictions_enabled, neuron::{DissolveStateAndAge, Neuron, NeuronBuilder, Visibility}, neuron_data_validation::{NeuronDataValidationSummary, NeuronDataValidator}, neuron_store::{ @@ -199,9 +200,6 @@ pub const PROPOSAL_MOTION_TEXT_BYTES_MAX: usize = 10000; // The minimum neuron dissolve delay (set when a neuron is first claimed) pub const INITIAL_NEURON_DISSOLVE_DELAY: u64 = 7 * ONE_DAY_SECONDS; -// The maximum dissolve delay allowed for a neuron. -pub const MAX_DISSOLVE_DELAY_SECONDS: u64 = 8 * ONE_YEAR_SECONDS; - // The age of a neuron that saturates the age bonus for the voting power // computation. pub const MAX_NEURON_AGE_FOR_AGE_BONUS: u64 = 4 * ONE_YEAR_SECONDS; @@ -286,6 +284,21 @@ pub const MAX_NEURONS_FUND_PARTICIPANTS: u64 = 5_000; /// in the same limit. const NEURON_RATE_LIMITER_KEY: &str = "ADD_NEURON"; +// The maximum dissolve delay allowed for a neuron. +pub const MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70: u64 = 8 * ONE_YEAR_SECONDS; +pub const MAX_DISSOLVE_DELAY_SECONDS_POST_MISSION_70: u64 = 2 * ONE_YEAR_SECONDS; + +/// Returns the maximum dissolve delay allowed for a neuron. After the flag is enabled, we can +/// replace `max_dissolve_delay_seconds()` with `MAX_DISSOLVE_DELAY_SECONDS` and set +/// `MAX_DISSOLVE_DELAY_SECONDS` to `MAX_DISSOLVE_DELAY_SECONDS_POST_MISSION_70`. +pub fn max_dissolve_delay_seconds() -> u64 { + if is_mission_70_voting_rewards_enabled() { + MAX_DISSOLVE_DELAY_SECONDS_POST_MISSION_70 + } else { + MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70 + } +} + impl GovernanceError { pub fn new(error_type: ErrorType) -> Self { Self { @@ -1328,6 +1341,21 @@ impl Governance { // A one-time data migration. governance.maybe_set_eight_year_gang_bonus_base(); + // Clamp all neuron dissolve delays to the Mission 70 maximum exactly once. + // The snapshot serves as the idempotency guard: if it's already populated, + // clamping has already run and we must not overwrite the pre-clamp record. + if is_mission_70_voting_rewards_enabled() + && governance + .heap_data + .neuron_id_to_pre_clamp_dissolve_state + .is_empty() + { + let now = governance.env.now(); + governance.heap_data.neuron_id_to_pre_clamp_dissolve_state = governance + .neuron_store + .clamp_dissolve_delay_for_all_neurons_or_panic(now); + } + governance } @@ -2965,7 +2993,7 @@ impl Governance { let dissolve_delay_seconds = std::cmp::min( disburse_to_neuron.dissolve_delay_seconds, - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ); let dissolve_state_and_age = if dissolve_delay_seconds > 0 { @@ -3805,7 +3833,7 @@ impl Governance { let nid = self.neuron_store.new_neuron_id(&mut *self.randomness)?; let dissolve_delay_seconds = std::cmp::min( reward_to_neuron.dissolve_delay_seconds, - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ); let dissolve_state_and_age = if dissolve_delay_seconds > 0 { diff --git a/rs/nns/governance/src/governance/create_neuron.rs b/rs/nns/governance/src/governance/create_neuron.rs index ad94a610d810..9dd533e8b725 100644 --- a/rs/nns/governance/src/governance/create_neuron.rs +++ b/rs/nns/governance/src/governance/create_neuron.rs @@ -1,6 +1,6 @@ use crate::{ governance::{ - Governance, INITIAL_NEURON_DISSOLVE_DELAY, LOG_PREFIX, MAX_DISSOLVE_DELAY_SECONDS, + Governance, INITIAL_NEURON_DISSOLVE_DELAY, LOG_PREFIX, max_dissolve_delay_seconds, }, neuron::{DissolveStateAndAge, NeuronBuilder}, pb::v1::{ @@ -73,12 +73,13 @@ impl Governance { ), )); } - if dissolve_delay_seconds > MAX_DISSOLVE_DELAY_SECONDS { + if dissolve_delay_seconds > max_dissolve_delay_seconds() { return Err(GovernanceError::new_with_message( ErrorType::InvalidCommand, format!( "Dissolve delay {dissolve_delay_seconds} is greater than the maximum \ - dissolve delay {MAX_DISSOLVE_DELAY_SECONDS}" + dissolve delay {}", + max_dissolve_delay_seconds() ), )); } diff --git a/rs/nns/governance/src/governance/tests/mod.rs b/rs/nns/governance/src/governance/tests/mod.rs index 0606e7e32400..fd1425946194 100644 --- a/rs/nns/governance/src/governance/tests/mod.rs +++ b/rs/nns/governance/src/governance/tests/mod.rs @@ -3,7 +3,7 @@ use crate::pb::v1::ExecuteNnsFunction; use crate::storage::with_voting_history_store; use crate::test_utils::MockRandomness; use crate::{ - governance::MAX_DISSOLVE_DELAY_SECONDS, + governance::MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, neuron::{DissolveStateAndAge, NeuronBuilder}, test_utils::{MockEnvironment, StubCMC, StubIcpLedger}, }; @@ -1735,7 +1735,7 @@ fn test_maybe_set_eight_year_gang_bonus_base() { let neuron = NeuronBuilder::new_for_test( 1, DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS, + dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, aging_since_timestamp_seconds: 0, }, ) diff --git a/rs/nns/governance/src/heap_governance_data.rs b/rs/nns/governance/src/heap_governance_data.rs index f6cdbc672749..6c93ac3b88e5 100644 --- a/rs/nns/governance/src/heap_governance_data.rs +++ b/rs/nns/governance/src/heap_governance_data.rs @@ -2,8 +2,8 @@ use crate::{ neuron::Neuron, pb::v1::{ Followees, Governance as GovernanceProto, MonthlyNodeProviderRewards, NetworkEconomics, - NeuronStakeTransfer, NodeProvider, ProposalData, RestoreAgingSummary, RewardEvent, Topic, - XdrConversionRate as XdrConversionRatePb, + NeuronDissolveStateSnapshot, NeuronStakeTransfer, NodeProvider, ProposalData, + RestoreAgingSummary, RewardEvent, Topic, XdrConversionRate as XdrConversionRatePb, governance::{GovernanceCachedMetrics, NeuronInFlightCommand}, }, }; @@ -37,6 +37,7 @@ pub struct HeapGovernanceData { pub restore_aging_summary: Option, pub topic_of_garbage_collected_proposals: HashMap, pub eight_year_gang_bonus_migration_done: bool, + pub neuron_id_to_pre_clamp_dissolve_state: HashMap, } /// Internal representation for `XdrConversionRatePb`. @@ -207,6 +208,7 @@ pub fn initialize_governance( restore_aging_summary, topic_of_garbage_collected_proposals: HashMap::new(), eight_year_gang_bonus_migration_done: false, + neuron_id_to_pre_clamp_dissolve_state: HashMap::new(), }; // Finally, return the result. @@ -247,6 +249,7 @@ pub fn split_governance_proto( restore_aging_summary, topic_of_garbage_collected_proposals, eight_year_gang_bonus_migration_done, + neuron_id_to_pre_clamp_dissolve_state, rng_seed, } = governance_proto; @@ -291,6 +294,7 @@ pub fn split_governance_proto( .map(|(k, v)| (k, Topic::try_from(v).unwrap_or(Topic::Unspecified))) .collect(), eight_year_gang_bonus_migration_done, + neuron_id_to_pre_clamp_dissolve_state, }, rng_seed, ) @@ -329,6 +333,7 @@ pub fn reassemble_governance_proto( restore_aging_summary, topic_of_garbage_collected_proposals, eight_year_gang_bonus_migration_done, + neuron_id_to_pre_clamp_dissolve_state, } = heap_governance_proto; let neuron_management_voting_period_seconds = Some(neuron_management_voting_period_seconds); @@ -360,6 +365,7 @@ pub fn reassemble_governance_proto( .map(|(k, v)| (k, v as i32)) .collect(), eight_year_gang_bonus_migration_done, + neuron_id_to_pre_clamp_dissolve_state, rng_seed: rng_seed.map(|seed| seed.to_vec()), } } @@ -368,6 +374,7 @@ pub fn reassemble_governance_proto( mod tests { use super::*; + use crate::governance::MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70; use crate::pb::v1::ProposalData; use maplit::{btreemap, hashmap}; @@ -400,6 +407,15 @@ mod tests { restore_aging_summary: None, topic_of_garbage_collected_proposals: hashmap! { 1 => Topic::Unspecified as i32 }, eight_year_gang_bonus_migration_done: true, + neuron_id_to_pre_clamp_dissolve_state: hashmap! { + 1 => NeuronDissolveStateSnapshot { + dissolve_state: Some( + crate::pb::v1::neuron_dissolve_state_snapshot::DissolveState::DissolveDelaySeconds( + MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, + ), + ), + }, + }, rng_seed: Some(vec![1_u8; 32]), } } diff --git a/rs/nns/governance/src/neuron/dissolve_state_and_age.rs b/rs/nns/governance/src/neuron/dissolve_state_and_age.rs index cdbd9240e5be..3e3c9bb3a694 100644 --- a/rs/nns/governance/src/neuron/dissolve_state_and_age.rs +++ b/rs/nns/governance/src/neuron/dissolve_state_and_age.rs @@ -1,5 +1,5 @@ use crate::{ - governance::MAX_DISSOLVE_DELAY_SECONDS, neuron::StoredDissolveStateAndAge, pb::v1::NeuronState, + governance::max_dissolve_delay_seconds, neuron::StoredDissolveStateAndAge, pb::v1::NeuronState, }; use ic_nns_governance_api::neuron::DissolveState as ApiDissolveState; @@ -154,7 +154,7 @@ impl DissolveStateAndAge { /// already dissolved, it transitions to a non-dissolving state with the new dissolve delay. If /// the neuron is dissolving, the dissolve timestamp is increased by the given number of /// seconds. If the neuron is not dissolving, the dissolve delay is increased by the given - /// number of seconds. The new dissolve delay is capped at MAX_DISSOLVE_DELAY_SECONDS. + /// number of seconds. The new dissolve delay is capped at max_dissolve_delay_seconds(). pub fn increase_dissolve_delay( self, now_seconds: u64, @@ -174,7 +174,7 @@ impl DissolveStateAndAge { } => { let new_delay_dissolve_delay_seconds = std::cmp::min( dissolve_delay_seconds.saturating_add(additional_dissolve_delay_seconds), - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ); Self::NotDissolving { dissolve_delay_seconds: new_delay_dissolve_delay_seconds, @@ -190,7 +190,7 @@ impl DissolveStateAndAge { when_dissolved_timestamp_seconds.saturating_sub(now_seconds); let new_delay_seconds = std::cmp::min( dissolve_delay_seconds.saturating_add(additional_dissolve_delay_seconds), - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ); let new_when_dissolved_timestamp_seconds = now_seconds.saturating_add(new_delay_seconds); @@ -201,7 +201,7 @@ impl DissolveStateAndAge { // This neuron is dissolved. Set it to non-dissolving. let new_delay_seconds = std::cmp::min( additional_dissolve_delay_seconds, - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ); Self::NotDissolving { dissolve_delay_seconds: new_delay_seconds, @@ -266,6 +266,29 @@ impl DissolveStateAndAge { Self::DissolvingOrDissolved { .. } => self, } } + + pub fn clamp_dissolve_delay(self, max_dissolve_delay_seconds: u64, now_seconds: u64) -> Self { + match self { + Self::NotDissolving { + dissolve_delay_seconds, + aging_since_timestamp_seconds, + } => Self::NotDissolving { + dissolve_delay_seconds: std::cmp::min( + dissolve_delay_seconds, + max_dissolve_delay_seconds, + ), + aging_since_timestamp_seconds, + }, + Self::DissolvingOrDissolved { + when_dissolved_timestamp_seconds, + } => Self::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: std::cmp::min( + when_dissolved_timestamp_seconds, + now_seconds.saturating_add(max_dissolve_delay_seconds), + ), + }, + } + } } #[cfg(test)] @@ -364,7 +387,7 @@ mod tests { for (aging_since_timestamp_seconds, expected_age_seconds) in [(0, NOW), (NOW - 1, 1), (NOW, 0), (NOW + 1, 0)] { - for dissolve_delay_seconds in [0, 1, 100, MAX_DISSOLVE_DELAY_SECONDS] { + for dissolve_delay_seconds in [0, 1, 100, max_dissolve_delay_seconds()] { assert_age_seconds( DissolveStateAndAge::NotDissolving { dissolve_delay_seconds, @@ -424,12 +447,12 @@ mod tests { }, ); - // Test that the dissolve delay is capped at MAX_DISSOLVE_DELAY_SECONDS. + // Test that the dissolve delay is capped at max_dissolve_delay_seconds(). assert_increase_dissolve_delay( dissolve_state_and_age, - MAX_DISSOLVE_DELAY_SECONDS as u32 + 1000, + max_dissolve_delay_seconds() as u32 + 1000, DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS, + dissolve_delay_seconds: max_dissolve_delay_seconds(), aging_since_timestamp_seconds: NOW, }, ); @@ -440,7 +463,7 @@ mod tests { fn test_increase_dissolve_delay_for_not_dissolving_neurons() { for current_aging_since_timestamp_seconds in [0, NOW - 1, NOW, NOW + 1, NOW + 2000] { for current_dissolve_delay_seconds in - [1, 10, 100, NOW, NOW + 1000, MAX_DISSOLVE_DELAY_SECONDS - 1] + [1, 10, 100, 1000, max_dissolve_delay_seconds() - 1] { assert_increase_dissolve_delay( DissolveStateAndAge::NotDissolving { @@ -456,15 +479,15 @@ mod tests { } } - // Test that the dissolve delay is capped at MAX_DISSOLVE_DELAY_SECONDS. + // Test that the dissolve delay is capped at max_dissolve_delay_seconds(). assert_increase_dissolve_delay( DissolveStateAndAge::NotDissolving { dissolve_delay_seconds: 1000, aging_since_timestamp_seconds: NOW, }, - MAX_DISSOLVE_DELAY_SECONDS as u32, + max_dissolve_delay_seconds() as u32, DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS, + dissolve_delay_seconds: max_dissolve_delay_seconds(), aging_since_timestamp_seconds: NOW, }, ); @@ -473,7 +496,7 @@ mod tests { #[test] fn test_increase_dissolve_delay_for_dissolving_neurons() { for when_dissolved_timestamp_seconds in - [NOW + 1, NOW + 1000, NOW + MAX_DISSOLVE_DELAY_SECONDS - 1] + [NOW + 1, NOW + 1000, NOW + max_dissolve_delay_seconds() - 1] { assert_increase_dissolve_delay( DissolveStateAndAge::DissolvingOrDissolved { @@ -486,14 +509,14 @@ mod tests { ); } - // Test that the dissolve delay is capped at MAX_DISSOLVE_DELAY_SECONDS. + // Test that the dissolve delay is capped at max_dissolve_delay_seconds(). assert_increase_dissolve_delay( DissolveStateAndAge::DissolvingOrDissolved { when_dissolved_timestamp_seconds: NOW + 1000, }, - MAX_DISSOLVE_DELAY_SECONDS as u32, + max_dissolve_delay_seconds() as u32, DissolveStateAndAge::DissolvingOrDissolved { - when_dissolved_timestamp_seconds: NOW + MAX_DISSOLVE_DELAY_SECONDS, + when_dissolved_timestamp_seconds: NOW + max_dissolve_delay_seconds(), }, ); } diff --git a/rs/nns/governance/src/neuron/types.rs b/rs/nns/governance/src/neuron/types.rs index 449882ed9d58..77e3d6c31ab7 100644 --- a/rs/nns/governance/src/neuron/types.rs +++ b/rs/nns/governance/src/neuron/types.rs @@ -1,18 +1,19 @@ use crate::{ DEFAULT_VOTING_POWER_REFRESHED_TIMESTAMP_SECONDS, governance::{ - LOG_PREFIX, MAX_DISSOLVE_DELAY_SECONDS, MAX_NEURON_AGE_FOR_AGE_BONUS, - MAX_NUM_HOT_KEYS_PER_NEURON, + LOG_PREFIX, MAX_NEURON_AGE_FOR_AGE_BONUS, MAX_NUM_HOT_KEYS_PER_NEURON, + max_dissolve_delay_seconds, }, neuron::{combine_aged_stakes, dissolve_state_and_age::DissolveStateAndAge, neuron_stake_e8s}, neuron_store::NeuronStoreError, pb::v1::{ self as pb, AbridgedNeuron, Ballot, BallotInfo, Followees, GovernanceError, - KnownNeuronData, MaturityDisbursement, NeuronStakeTransfer, NeuronState, NeuronType, Topic, - Vote, VotingPowerEconomics, + KnownNeuronData, MaturityDisbursement, NeuronDissolveStateSnapshot, NeuronStakeTransfer, + NeuronState, NeuronType, Topic, Vote, VotingPowerEconomics, abridged_neuron::DissolveState, governance_error::ErrorType, manage_neuron::{Configure, configure::Operation}, + neuron_dissolve_state_snapshot, }, }; use ic_base_types::PrincipalId; @@ -351,11 +352,11 @@ impl Neuron { // future. let d = std::cmp::min( self.dissolve_delay_seconds(now_seconds), - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), ) as u128; // 'd_stake' is the stake with bonus for dissolve delay. - let d_stake = - stake.saturating_add((stake.saturating_mul(d)) / (MAX_DISSOLVE_DELAY_SECONDS as u128)); + let d_stake = stake + .saturating_add((stake.saturating_mul(d)) / (max_dissolve_delay_seconds() as u128)); // Sanity check. assert!(d_stake <= 2 * stake); // The voting power is also a function of the age of the @@ -2041,5 +2042,69 @@ impl TryFrom for DissolveStateAndAge { } } +impl AbridgedNeuron { + /// Clamps the dissolve delay to the current maximum, and returns a snapshot of the original + /// dissolve state for disaster recovery. + pub fn clamp_dissolve_delay_or_panic( + &mut self, + now_seconds: u64, + ) -> NeuronDissolveStateSnapshot { + // Usually, when we want to modify a neuron, we should use `with_neuron_mut`, which should + // allow us the work with the "Validated" types, and not have to handle the "raw" data in + // the storage layer. However, since we would like to change every neuron in the + // post_upgrade, and given that `with_neuron_mut` can be expensive as it reads every + // collection (e.g. recent ballots, followees, etc.), we will use the "raw" data in the + // storage layer here and do the same conversions to the "Validated" types as + // `with_neuron_mut` does. Therefore, there are 4 steps involved: convert the "raw" data to + // the "validated" types, record a snapshot of the original dissolve state, clamp the + // dissolve delay, and convert back to the "raw" data. + + // Step 1. Convert the "raw" data to the "validated" types. + let stored_dissolve_state_and_age = StoredDissolveStateAndAge { + dissolve_state: self.dissolve_state, + aging_since_timestamp_seconds: self.aging_since_timestamp_seconds, + }; + let validated_dissolve_state_and_age = + DissolveStateAndAge::try_from(stored_dissolve_state_and_age) + .expect("Expected the dissolve state and age to be valid"); + + // Step 2. Record the original dissolve state before clamping. + let original_dissolve_state_snapshot = match validated_dissolve_state_and_age { + DissolveStateAndAge::NotDissolving { + dissolve_delay_seconds, + aging_since_timestamp_seconds: _, + } => NeuronDissolveStateSnapshot { + dissolve_state: Some( + neuron_dissolve_state_snapshot::DissolveState::DissolveDelaySeconds( + dissolve_delay_seconds, + ), + ), + }, + DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds, + } => NeuronDissolveStateSnapshot { + dissolve_state: Some( + neuron_dissolve_state_snapshot::DissolveState::WhenDissolvedTimestampSeconds( + when_dissolved_timestamp_seconds, + ), + ), + }, + }; + + // Step 3. Clamp the dissolve delay. + let clamped_dissolve_state_and_age = validated_dissolve_state_and_age + .clamp_dissolve_delay(max_dissolve_delay_seconds(), now_seconds); + + // Step 4. Convert back to the "raw" data and update the neuron. + let clamped_stored_dissolve_state_and_age = + StoredDissolveStateAndAge::from(clamped_dissolve_state_and_age); + self.dissolve_state = clamped_stored_dissolve_state_and_age.dissolve_state; + self.aging_since_timestamp_seconds = + clamped_stored_dissolve_state_and_age.aging_since_timestamp_seconds; + + original_dissolve_state_snapshot + } +} + #[cfg(test)] mod tests; diff --git a/rs/nns/governance/src/neuron/types/tests.rs b/rs/nns/governance/src/neuron/types/tests.rs index 400e5dc269be..145557235341 100644 --- a/rs/nns/governance/src/neuron/types/tests.rs +++ b/rs/nns/governance/src/neuron/types/tests.rs @@ -445,9 +445,7 @@ fn increase_dissolve_delay_does_not_set_age_for_non_dissolving_neurons() { // Test cases for current_aging_since_timestamp_seconds in [0, NOW - 1, NOW, NOW + 1, NOW + 2000] { - for current_dissolve_delay_seconds in - [1, 10, 100, NOW, NOW + 1000, (ONE_DAY_SECONDS * 365 * 8)] - { + for current_dissolve_delay_seconds in [1, 10, 100, 1000, max_dissolve_delay_seconds() - 1] { test_increase_dissolve_delay_by_1_for_non_dissolving_neuron( current_aging_since_timestamp_seconds, current_dissolve_delay_seconds, @@ -522,7 +520,10 @@ fn test_neuron_configure_dissolve_delay() { ) .unwrap(); assert_eq!(neuron.state(now), NeuronState::NotDissolving); - assert_eq!(neuron.dissolve_delay_seconds(now), 8 * ONE_YEAR_SECONDS); + assert_eq!( + neuron.dissolve_delay_seconds(now), + max_dissolve_delay_seconds() + ); // Step 5: start dissolving the neuron. neuron @@ -537,7 +538,7 @@ fn test_neuron_configure_dissolve_delay() { assert_eq!(neuron.state(now), NeuronState::Dissolving); // Step 7: advance the time by 8 years - 1 second and see that the neuron is still dissolving. - let now = now + 8 * ONE_YEAR_SECONDS - 1; + let now = now + max_dissolve_delay_seconds() - 1; assert_eq!(neuron.state(now), NeuronState::Dissolving); // Step 8: advance the time by 1 second and see that the neuron is now dissolved. diff --git a/rs/nns/governance/src/neuron_store.rs b/rs/nns/governance/src/neuron_store.rs index 8f7824d661f6..c7fc5bf9ba18 100644 --- a/rs/nns/governance/src/neuron_store.rs +++ b/rs/nns/governance/src/neuron_store.rs @@ -3,7 +3,10 @@ use crate::{ governance::{LOG_PREFIX, TimeWarp}, neuron::types::Neuron, neurons_fund::neurons_fund_neuron::pick_most_important_hotkeys, - pb::v1::{GovernanceError, Topic, VotingPowerEconomics, governance_error::ErrorType}, + pb::v1::{ + GovernanceError, NeuronDissolveStateSnapshot, Topic, VotingPowerEconomics, + governance_error::ErrorType, + }, storage::{ neuron_indexes::CorruptedNeuronIndexes, neurons::NeuronSections, with_stable_neuron_indexes, with_stable_neuron_indexes_mut, with_stable_neuron_store, @@ -780,6 +783,15 @@ impl NeuronStore { }); } + pub fn clamp_dissolve_delay_for_all_neurons_or_panic( + &mut self, + now_seconds: u64, + ) -> HashMap { + with_stable_neuron_store_mut(|stable_neuron_store| { + stable_neuron_store.clamp_dissolve_delay_for_all_neurons_or_panic(now_seconds) + }) + } + // Below are indexes related methods. They don't have a unified interface yet, but NNS1-2507 will change that. // Read methods for indexes. diff --git a/rs/nns/governance/src/neuron_store/metrics/tests.rs b/rs/nns/governance/src/neuron_store/metrics/tests.rs index 956892700067..341bbbad4937 100644 --- a/rs/nns/governance/src/neuron_store/metrics/tests.rs +++ b/rs/nns/governance/src/neuron_store/metrics/tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::{ + governance::max_dissolve_delay_seconds, + is_mission_70_voting_rewards_enabled, neuron::{DissolveStateAndAge, NeuronBuilder}, pb::v1::{KnownNeuronData, MaturityDisbursement, NeuronType}, }; @@ -441,7 +443,7 @@ fn test_compute_neuron_metrics_non_self_authenticating() { controller_of_neuron_1, // Total voting power bonus: 2x * 1.125x = 2.25x DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: 8 * ONE_YEAR_SECONDS, // 100% (equivlanetly, 2x) dissolve delay bonus + dissolve_delay_seconds: max_dissolve_delay_seconds(), // 100% (equivalently, 2x) dissolve delay bonus aging_since_timestamp_seconds: now_seconds - 2 * ONE_YEAR_SECONDS, // 12.5% (equivalently 1.125x) age bonus }, now_seconds, @@ -473,7 +475,7 @@ fn test_compute_neuron_metrics_non_self_authenticating() { controller_of_neuron_3, // Total voting power bonus: 1.5x * 1.25x = 1.875x DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: 4 * ONE_YEAR_SECONDS, // 50% (equivalently, 1.5x) dissolve delay bonus + dissolve_delay_seconds: max_dissolve_delay_seconds() / 2, // 50% (equivalently, 1.5x) dissolve delay bonus aging_since_timestamp_seconds: now_seconds - 4 * ONE_YEAR_SECONDS, // 25% (equivalently 1.25x) age bonus }, now_seconds, @@ -523,6 +525,22 @@ fn test_compute_neuron_metrics_non_self_authenticating() { } = neuron_store.compute_neuron_metrics(E8, &VotingPowerEconomics::DEFAULT, now_seconds); // Step 3: Inspect results. + // + // bucket_for_half_max: neuron_3 has a dissolve delay equal to max_dissolve_delay_seconds() / 2 + // (mapped to bucket 8 when the max is long, 2 when the max is short). + let bucket_for_half_max = if is_mission_70_voting_rewards_enabled() { + 2 + } else { + 8 + }; + // bucket_for_max: neuron_1 has a dissolve delay equal to max_dissolve_delay_seconds() + // (mapped to bucket 16 when the max is long, 4 when the max is short). + let bucket_for_max = if is_mission_70_voting_rewards_enabled() { + 4 + } else { + 16 + }; + assert_eq!( non_self_authenticating_controller_neuron_subset_metrics, NeuronSubsetMetrics { @@ -542,36 +560,36 @@ fn test_compute_neuron_metrics_non_self_authenticating() { // Analogous to the vanilla count field. count_buckets: hashmap! { - 8 => 1, // 1 neuron with 4 year dissolve delay. - 16 => 1, // 1 neuron with 8 year dissolve delay. + bucket_for_half_max => 1, // 1 neuron with max/2 dissolve delay. + bucket_for_max => 1, // 1 neuron with max dissolve delay. }, // ICP-like resources. staked_e8s_buckets: hashmap! { - 8 => 300_000_000, - 16 => 100, + bucket_for_half_max => 300_000_000, + bucket_for_max => 100, }, staked_maturity_e8s_equivalent_buckets: hashmap! { - 8 => 303_000_000, - 16 => 101, + bucket_for_half_max => 303_000_000, + bucket_for_max => 101, }, maturity_e8s_equivalent_buckets: hashmap! { - 8 => 330_000_000, - 16 => 110, + bucket_for_half_max => 330_000_000, + bucket_for_max => 110, }, // Analogous to total_voting_power. voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, deciding_voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, potential_voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, }, ); @@ -598,7 +616,7 @@ fn test_compute_neuron_metrics_public_neurons() { PrincipalId::new_user_test_id(1), // Total voting power bonus: 2x * 1.125x = 2.25x DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: 8 * ONE_YEAR_SECONDS, // 100% (equivlanetly, 2x) dissolve delay bonus + dissolve_delay_seconds: max_dissolve_delay_seconds(), // 100% (equivalently, 2x) dissolve delay bonus aging_since_timestamp_seconds: now_seconds - 2 * ONE_YEAR_SECONDS, // 12.5% (equivalently 1.125x) age bonus }, now_seconds - 10 * ONE_YEAR_SECONDS, @@ -633,7 +651,7 @@ fn test_compute_neuron_metrics_public_neurons() { PrincipalId::new_user_test_id(3), // Total voting power bonus: 1.5x * 1.25x = 1.875x DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: 4 * ONE_YEAR_SECONDS, // 50% (equivalently, 1.5x) dissolve delay bonus + dissolve_delay_seconds: max_dissolve_delay_seconds() / 2, aging_since_timestamp_seconds: now_seconds - 4 * ONE_YEAR_SECONDS, // 25% (equivalently 1.25x) age bonus }, now_seconds - 10 * ONE_YEAR_SECONDS, @@ -681,6 +699,22 @@ fn test_compute_neuron_metrics_public_neurons() { } = neuron_store.compute_neuron_metrics(E8, &VotingPowerEconomics::DEFAULT, now_seconds); // Step 3: Inspect results. + // + // bucket_for_half_max: neuron_3 has a dissolve delay equal to max_dissolve_delay_seconds() / 2 + // (mapped to bucket 8 when the max is long, 2 when the max is short). + let bucket_for_half_max = if is_mission_70_voting_rewards_enabled() { + 2 + } else { + 8 + }; + // bucket_for_max: neuron_1 has a dissolve delay equal to max_dissolve_delay_seconds() + // (mapped to bucket 16 when the max is long, 4 when the max is short). + let bucket_for_max = if is_mission_70_voting_rewards_enabled() { + 4 + } else { + 16 + }; + assert_eq!( public_neuron_subset_metrics, NeuronSubsetMetrics { @@ -700,36 +734,36 @@ fn test_compute_neuron_metrics_public_neurons() { // Analogous to the vanilla count field. count_buckets: hashmap! { - 8 => 1, // 1 neuron with 4 year dissolve delay. - 16 => 1, // 1 neuron with 8 year dissolve delay. + bucket_for_half_max => 1, // 1 neuron with max/2 dissolve delay. + bucket_for_max => 1, // 1 neuron with max dissolve delay. }, // ICP-like resources. staked_e8s_buckets: hashmap! { - 8 => 300_000_000, - 16 => 100, + bucket_for_half_max => 300_000_000, + bucket_for_max => 100, }, staked_maturity_e8s_equivalent_buckets: hashmap! { - 8 => 303_000_000, - 16 => 101, + bucket_for_half_max => 303_000_000, + bucket_for_max => 101, }, maturity_e8s_equivalent_buckets: hashmap! { - 8 => 330_000_000, - 16 => 110, + bucket_for_half_max => 330_000_000, + bucket_for_max => 110, }, // Analogous to total_voting_power. voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, deciding_voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, potential_voting_power_buckets: hashmap! { - 8 => voting_power_3, - 16 => voting_power_1, + bucket_for_half_max => voting_power_3, + bucket_for_max => voting_power_1, }, }, ); @@ -755,7 +789,7 @@ fn test_compute_neuron_metrics_stale_and_expired_voting_power_neurons() { // Total voting power bonus: 2x * 1.125x = 2.25x let dissolve_state_and_age = DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: 8 * ONE_YEAR_SECONDS, // 100% (equivlanetly, 2x) dissolve delay bonus + dissolve_delay_seconds: max_dissolve_delay_seconds(), // 100% (equivalently, 2x) dissolve delay bonus aging_since_timestamp_seconds: now_seconds - 2 * ONE_YEAR_SECONDS, // 12.5% (equivalently 1.125x) age bonus }; let total_bonus_multiplier = 2.25; @@ -835,6 +869,16 @@ fn test_compute_neuron_metrics_stale_and_expired_voting_power_neurons() { } = neuron_store.compute_neuron_metrics(E8, &VotingPowerEconomics::DEFAULT, now_seconds); // Step 3: Inspect results. + // + // All neurons are created with the maximum dissolve delay, and we bucket by + // that effective maximum: + // - Pre-mission-70: max dissolve delay is 8 years -> bucket 16 + // - Post-mission-70: max dissolve delay is clamped to 2 years -> bucket 4 + let bucket_for_max = if is_mission_70_voting_rewards_enabled() { + 4 + } else { + 16 + }; assert_eq!( declining_voting_power_neuron_subset_metrics, @@ -862,30 +906,30 @@ fn test_compute_neuron_metrics_stale_and_expired_voting_power_neurons() { // Analogous to the vanilla count field. count_buckets: hashmap! { - 16 => 1, // 1 neuron with 4 year dissolve delay. + bucket_for_max => 1, // 1 neuron with max dissolve delay. }, // ICP-like resources. staked_e8s_buckets: hashmap! { - 16 => 200_000, + bucket_for_max => 200_000, }, staked_maturity_e8s_equivalent_buckets: hashmap! { - 16 => 202_000, + bucket_for_max => 202_000, }, maturity_e8s_equivalent_buckets: hashmap! { - 16 => 220_000, + bucket_for_max => 220_000, }, // Analogous to total_voting_power. voting_power_buckets: hashmap! { - 16 => stale_potential_voting_power, + bucket_for_max => stale_potential_voting_power, }, // Ditto earlier comments about "right" voting power. deciding_voting_power_buckets: hashmap! { - 16 => stale_potential_voting_power / 2, + bucket_for_max => stale_potential_voting_power / 2, }, potential_voting_power_buckets: hashmap! { - 16 => stale_potential_voting_power, + bucket_for_max => stale_potential_voting_power, }, }, ); @@ -913,30 +957,30 @@ fn test_compute_neuron_metrics_stale_and_expired_voting_power_neurons() { // Analogous to the vanilla count field. count_buckets: hashmap! { - 16 => 1, // 1 neuron with 4 year dissolve delay. + bucket_for_max => 1, // 1 neuron with max dissolve delay. }, // ICP-like resources. staked_e8s_buckets: hashmap! { - 16 => 300_000_000, + bucket_for_max => 300_000_000, }, staked_maturity_e8s_equivalent_buckets: hashmap! { - 16 => 303_000_000, + bucket_for_max => 303_000_000, }, maturity_e8s_equivalent_buckets: hashmap! { - 16 => 330_000_000, + bucket_for_max => 330_000_000, }, // Analogous to total_voting_power. voting_power_buckets: hashmap! { - 16 => expired_potential_voting_power, + bucket_for_max => expired_potential_voting_power, }, // Ditto earlier comment about "right" voting power. deciding_voting_power_buckets: hashmap! { - 16 => 0, + bucket_for_max => 0, }, potential_voting_power_buckets: hashmap! { - 16 => expired_potential_voting_power, + bucket_for_max => expired_potential_voting_power, }, }, ); diff --git a/rs/nns/governance/src/neuron_store/neuron_store_tests.rs b/rs/nns/governance/src/neuron_store/neuron_store_tests.rs index 5698e7c8f7a7..eeb1fa3a4d0f 100644 --- a/rs/nns/governance/src/neuron_store/neuron_store_tests.rs +++ b/rs/nns/governance/src/neuron_store/neuron_store_tests.rs @@ -1,7 +1,11 @@ use super::*; use crate::{ + governance::max_dissolve_delay_seconds, neuron::{DissolveStateAndAge, NeuronBuilder}, - pb::v1::{BallotInfo, Followees, KnownNeuronData, MaturityDisbursement}, + pb::v1::{ + BallotInfo, Followees, KnownNeuronData, MaturityDisbursement, NeuronDissolveStateSnapshot, + neuron_dissolve_state_snapshot::DissolveState as SnapshotDissolveState, + }, storage::{with_stable_neuron_indexes, with_voting_history_store}, }; use ic_nervous_system_common::ONE_MONTH_SECONDS; @@ -1053,3 +1057,140 @@ fn test_record_neuron_vote() { }); assert_eq!(voting_history, vec![(ProposalId { id: 1 }, Vote::Yes)]); } + +#[test] +fn test_clamp_dissolve_delay_for_all_neurons_multiple_neurons() { + // Step 1. Create the neurons with various dissolve states. + let now_seconds = 1_000_000_u64; + // Neuron 1: NotDissolving above max - should be clamped + let neuron_1 = simple_neuron_builder(1) + .with_dissolve_state_and_age(DissolveStateAndAge::NotDissolving { + dissolve_delay_seconds: max_dissolve_delay_seconds() + 100_000, + aging_since_timestamp_seconds: now_seconds - 1000, + }) + .build(); + // Neuron 2: NotDissolving below max - should remain unchanged + let neuron_2 = simple_neuron_builder(2) + .with_dissolve_state_and_age(DissolveStateAndAge::NotDissolving { + dissolve_delay_seconds: 50_000, + aging_since_timestamp_seconds: now_seconds - 2000, + }) + .build(); + // Neuron 3: Dissolving above max - should be clamped + let neuron_3 = simple_neuron_builder(3) + .with_dissolve_state_and_age(DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds + max_dissolve_delay_seconds() + 100_000, + }) + .build(); + // Neuron 4: Dissolving below max - should remain unchanged + let neuron_4 = simple_neuron_builder(4) + .with_dissolve_state_and_age(DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds + 50_000, + }) + .build(); + // Neuron 5: Already dissolved - should remain unchanged + let neuron_5 = simple_neuron_builder(5) + .with_dissolve_state_and_age(DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds - 10_000, + }) + .build(); + let mut neuron_store = NeuronStore::new(btreemap! { + 1 => neuron_1, + 2 => neuron_2, + 3 => neuron_3, + 4 => neuron_4, + 5 => neuron_5, + }); + + // Step 2. Clamp the dissolve delay for all neurons. + let snapshot = neuron_store.clamp_dissolve_delay_for_all_neurons_or_panic(now_seconds); + + // Step 3. Verify the snapshot contains the pre-clamp dissolve states. + assert_eq!( + snapshot, + hashmap! { + 1 => NeuronDissolveStateSnapshot { + dissolve_state: Some(SnapshotDissolveState::DissolveDelaySeconds( + max_dissolve_delay_seconds() + 100_000, + )), + }, + 2 => NeuronDissolveStateSnapshot { + dissolve_state: Some(SnapshotDissolveState::DissolveDelaySeconds(50_000)), + }, + 3 => NeuronDissolveStateSnapshot { + dissolve_state: Some(SnapshotDissolveState::WhenDissolvedTimestampSeconds( + now_seconds + max_dissolve_delay_seconds() + 100_000, + )), + }, + 4 => NeuronDissolveStateSnapshot { + dissolve_state: Some(SnapshotDissolveState::WhenDissolvedTimestampSeconds( + now_seconds + 50_000, + )), + }, + 5 => NeuronDissolveStateSnapshot { + dissolve_state: Some(SnapshotDissolveState::WhenDissolvedTimestampSeconds( + now_seconds - 10_000, + )), + }, + } + ); + + // Step 4. Verify the post-clamp neuron states. + // Neuron 1: NotDissolving clamped to max + neuron_store + .with_neuron(&NeuronId { id: 1 }, |neuron| { + assert_eq!( + neuron.dissolve_state_and_age(), + DissolveStateAndAge::NotDissolving { + dissolve_delay_seconds: max_dissolve_delay_seconds(), + aging_since_timestamp_seconds: now_seconds - 1000, + } + ); + }) + .unwrap(); + // Neuron 2: NotDissolving unchanged + neuron_store + .with_neuron(&NeuronId { id: 2 }, |neuron| { + assert_eq!( + neuron.dissolve_state_and_age(), + DissolveStateAndAge::NotDissolving { + dissolve_delay_seconds: 50_000, + aging_since_timestamp_seconds: now_seconds - 2000, + } + ); + }) + .unwrap(); + // Neuron 3: Dissolving clamped to now + max + neuron_store + .with_neuron(&NeuronId { id: 3 }, |neuron| { + assert_eq!( + neuron.dissolve_state_and_age(), + DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds + max_dissolve_delay_seconds(), + } + ); + }) + .unwrap(); + // Neuron 4: Dissolving unchanged + neuron_store + .with_neuron(&NeuronId { id: 4 }, |neuron| { + assert_eq!( + neuron.dissolve_state_and_age(), + DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds + 50_000, + } + ); + }) + .unwrap(); + // Neuron 5: Already dissolved unchanged + neuron_store + .with_neuron(&NeuronId { id: 5 }, |neuron| { + assert_eq!( + neuron.dissolve_state_and_age(), + DissolveStateAndAge::DissolvingOrDissolved { + when_dissolved_timestamp_seconds: now_seconds - 10_000, + } + ); + }) + .unwrap(); +} diff --git a/rs/nns/governance/src/storage/neurons.rs b/rs/nns/governance/src/storage/neurons.rs index 35e324a0f457..fe72165d1e9c 100644 --- a/rs/nns/governance/src/storage/neurons.rs +++ b/rs/nns/governance/src/storage/neurons.rs @@ -1,10 +1,10 @@ use crate::{ - governance::MAX_DISSOLVE_DELAY_SECONDS, + governance::MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, neuron::{DecomposedNeuron, Neuron}, neuron_store::NeuronStoreError, pb::v1::{ AbridgedNeuron, BallotInfo, Followees, KnownNeuronData, MaturityDisbursement, - NeuronStakeTransfer, Topic, abridged_neuron::DissolveState, + NeuronDissolveStateSnapshot, NeuronStakeTransfer, Topic, abridged_neuron::DissolveState, }, storage::validate_stable_btree_map, }; @@ -768,7 +768,7 @@ where self.with_main_part_mut(neuron_id, |abridged_neuron| { let has_maximum_dissolve_delay = abridged_neuron.dissolve_state == Some(DissolveState::DissolveDelaySeconds( - MAX_DISSOLVE_DELAY_SECONDS, + MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, )); abridged_neuron.eight_year_gang_bonus_base_e8s = if has_maximum_dissolve_delay { abridged_neuron @@ -782,6 +782,26 @@ where .expect("Failed to set eight year gang bonus base for neuron"); } } + + pub fn clamp_dissolve_delay_for_all_neurons_or_panic( + &mut self, + now_seconds: u64, + ) -> HashMap { + let neuron_ids = self.main.keys().collect::>(); + let mut pre_clamp_dissolve_states = HashMap::new(); + for neuron_id in neuron_ids { + let mut snapshot = None; + self.with_main_part_mut(neuron_id, |abridged_neuron| { + snapshot = Some(abridged_neuron.clamp_dissolve_delay_or_panic(now_seconds)); + }) + .expect("Failed to clamp dissolve delay for neuron"); + pre_clamp_dissolve_states.insert( + neuron_id.id, + snapshot.expect("snapshot must be set inside with_main_part_mut"), + ); + } + pre_clamp_dissolve_states + } } /// Number of entries for each section of the neuron storage. Only the ones needed are defined. diff --git a/rs/nns/governance/src/storage/neurons/neurons_tests.rs b/rs/nns/governance/src/storage/neurons/neurons_tests.rs index 3e994e0c9f17..afe52ba0184d 100644 --- a/rs/nns/governance/src/storage/neurons/neurons_tests.rs +++ b/rs/nns/governance/src/storage/neurons/neurons_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::{ - governance::MAX_DISSOLVE_DELAY_SECONDS, + governance::MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, neuron::{DissolveStateAndAge, NeuronBuilder}, pb::v1::{MaturityDisbursement, Vote}, }; @@ -870,7 +870,7 @@ fn test_set_eight_year_gang_bonus_base_e8s_for_all_neurons() { Subaccount::from(&controller), controller, DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS, + dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70, aging_since_timestamp_seconds: 123_456_789, }, 123_456_789, @@ -886,7 +886,7 @@ fn test_set_eight_year_gang_bonus_base_e8s_for_all_neurons() { Subaccount::from(&PrincipalId::new_user_test_id(2)), PrincipalId::new_user_test_id(2), DissolveStateAndAge::NotDissolving { - dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS - 1, + dissolve_delay_seconds: MAX_DISSOLVE_DELAY_SECONDS_PRE_MISSION_70 - 1, aging_since_timestamp_seconds: 123_456_789, }, 123_456_789, diff --git a/rs/nns/governance/src/voting.rs b/rs/nns/governance/src/voting.rs index 5e922aa33138..7a376c57af24 100644 --- a/rs/nns/governance/src/voting.rs +++ b/rs/nns/governance/src/voting.rs @@ -595,6 +595,7 @@ mod test { use crate::test_utils::MockRandomness; use crate::{ governance::{Governance, REWARD_DISTRIBUTION_PERIOD_SECONDS}, + is_mission_70_voting_rewards_enabled, neuron::{DissolveStateAndAge, Neuron, NeuronBuilder}, neuron_store::NeuronStore, pb::v1::{ @@ -867,11 +868,20 @@ mod test { 6 => make_ballot(deciding_voting_power(NeuronId { id: 6 }), Vote::Unspecified), } ); + // Each neuron has 100 stake and 6 months dissolve delay. + // With 2-year max (mission 70): 6 months / 2 years = 25% bonus → 1.25x → 100 * 1.25 = 125 + // With 8-year max (pre mission 70): 6 months / 8 years = 6.25% bonus → 1.0625x → 100 * 1.0625 = 106 + // 5 neurons voting yes, 6 neurons total. + let (expected_yes, expected_total) = if is_mission_70_voting_rewards_enabled() { + (625, 750) // 5 * 125, 6 * 125 + } else { + (530, 636) // 5 * 106, 6 * 106 + }; let expected_tally = Tally { timestamp_seconds: 234, - yes: 530, + yes: expected_yes, no: 0, - total: 636, + total: expected_total, }; assert_eq!( governance diff --git a/rs/nns/governance/tests/governance.rs b/rs/nns/governance/tests/governance.rs index c63a3d82dfa7..231061e967f8 100644 --- a/rs/nns/governance/tests/governance.rs +++ b/rs/nns/governance/tests/governance.rs @@ -46,15 +46,16 @@ use ic_nns_governance::{ DEFAULT_VOTING_POWER_REFRESHED_TIMESTAMP_SECONDS, governance::{ Environment, Governance, HeapGrowthPotential, INITIAL_NEURON_DISSOLVE_DELAY, - MAX_DISSOLVE_DELAY_SECONDS, MAX_NEURON_AGE_FOR_AGE_BONUS, MAX_NEURON_CREATION_SPIKE, + MAX_NEURON_AGE_FOR_AGE_BONUS, MAX_NEURON_CREATION_SPIKE, MAX_NUMBER_OF_PROPOSALS_WITH_BALLOTS, PROPOSAL_MOTION_TEXT_BYTES_MAX, REWARD_DISTRIBUTION_PERIOD_SECONDS, WAIT_FOR_QUIET_DEADLINE_INCREASE_SECONDS, - get_node_provider_reward, + get_node_provider_reward, max_dissolve_delay_seconds, test_data::{ CREATE_SERVICE_NERVOUS_SYSTEM, CREATE_SERVICE_NERVOUS_SYSTEM_WITH_MATCHED_FUNDING, }, }, governance_proto_builder::GovernanceProtoBuilder, + is_mission_70_voting_rewards_enabled, pb::v1::{ AddOrRemoveNodeProvider, Ballot, BallotInfo, CreateServiceNervousSystem, Empty, ExecuteNnsFunction, Followees, GovernanceError, IdealMatchedParticipationFunction, @@ -169,9 +170,11 @@ const NOTDISSOLVING_MIN_DISSOLVE_DELAY_TO_VOTE: Option = Some( - api::neuron::DissolveState::DissolveDelaySeconds(MAX_DISSOLVE_DELAY_SECONDS), -); +fn not_dissolving_max_dissolve_delay() -> Option { + Some(api::neuron::DissolveState::DissolveDelaySeconds( + max_dissolve_delay_seconds(), + )) +} const MANAGER_ID: u64 = 1000; const NEURON_1_CONTROLLER: u64 = 100; @@ -422,7 +425,7 @@ fn test_two_neuron_disagree_identical_stake_longer_dissolve_wins() { check_proposal_status_after_voting_and_after_expiration( vec![ api::Neuron { - dissolve_state: NOTDISSOLVING_MAX_DISSOLVE_DELAY, + dissolve_state: not_dissolving_max_dissolve_delay(), cached_neuron_stake_e8s: 1, ..api::Neuron::default() }, @@ -439,7 +442,7 @@ fn test_two_neuron_disagree_identical_stake_longer_dissolve_wins() { check_proposal_status_after_voting_and_after_expiration( vec![ api::Neuron { - dissolve_state: NOTDISSOLVING_MAX_DISSOLVE_DELAY, + dissolve_state: not_dissolving_max_dissolve_delay(), cached_neuron_stake_e8s: 21, ..api::Neuron::default() }, @@ -456,7 +459,7 @@ fn test_two_neuron_disagree_identical_stake_longer_dissolve_wins() { check_proposal_status_after_voting_and_after_expiration( vec![ api::Neuron { - dissolve_state: NOTDISSOLVING_MAX_DISSOLVE_DELAY, + dissolve_state: not_dissolving_max_dissolve_delay(), cached_neuron_stake_e8s: 21, ..api::Neuron::default() }, @@ -2751,7 +2754,15 @@ async fn test_reward_event_proposals_last_longer_than_reward_period() { }); let expected_distributed_e8s_equivalent = (expected_available_e8s_equivalent as f64 * neuron_share) as u64; - assert_eq!(expected_distributed_e8s_equivalent, 15); + // The value depends on the dissolve delay bonus: + // - With 2-year max (mission 70): 1 year → 50% bonus → higher voting power ratio → 16 + // - With 8-year max (pre mission 70): 1 year → 12.5% bonus → lower voting power ratio → 15 + let expected_value = if is_mission_70_voting_rewards_enabled() { + 16 + } else { + 15 + }; + assert_eq!(expected_distributed_e8s_equivalent, expected_value); assert_eq!( *gov.latest_reward_event(), RewardEvent { @@ -4380,10 +4391,14 @@ fn create_mature_neuron(dissolved: bool) -> (fake::FakeDriver, Governance, api:: ); // Make sure the neuron was created with the right details. - let expected_voting_power = neuron_stake_e8s - // Age bonus. - * 17 - / 16; + // Voting power = stake * (1 + dissolve_delay_bonus). + // With 2-year max (mission 70): 6 months / 2 years = 25% bonus → multiplier = 1.25 = 5/4 + // With 8-year max (pre mission 70): 6 months / 8 years = 6.25% bonus → multiplier = 1.0625 = 17/16 + let expected_voting_power = if is_mission_70_voting_rewards_enabled() { + neuron_stake_e8s * 5 / 4 + } else { + neuron_stake_e8s * 17 / 16 + }; assert_eq!( gov.get_full_neuron(&id, &from).unwrap(), api::Neuron { @@ -8844,8 +8859,8 @@ fn test_increase_dissolve_delay() { &mut gov, principal_id, 1, - u32::try_from(MAX_DISSOLVE_DELAY_SECONDS + 1) - .expect("MAX_DISSOLVE_DELAY_SECONDS larger than u32"), + u32::try_from(max_dissolve_delay_seconds() + 1) + .expect("max_dissolve_delay_seconds larger than u32"), ); let neuron_info = gov .get_neuron_info(&NeuronId { id: 1 }, *RANDOM_PRINCIPAL_ID) @@ -8853,7 +8868,7 @@ fn test_increase_dissolve_delay() { assert_eq!(neuron_info.state, NeuronState::NotDissolving as i32); assert_eq!( neuron_info.dissolve_delay_seconds, - MAX_DISSOLVE_DELAY_SECONDS + max_dissolve_delay_seconds() ); // Tests for neuron 2. Dissolving. @@ -8871,8 +8886,8 @@ fn test_increase_dissolve_delay() { &mut gov, principal_id, 2, - u32::try_from(MAX_DISSOLVE_DELAY_SECONDS + 1) - .expect("MAX_DISSOLVE_DELAY_SECONDS larger than u32"), + u32::try_from(max_dissolve_delay_seconds() + 1) + .expect("max_dissolve_delay_seconds larger than u32"), ); let neuron_info = gov .get_neuron_info(&NeuronId { id: 2 }, *RANDOM_PRINCIPAL_ID) @@ -8880,7 +8895,7 @@ fn test_increase_dissolve_delay() { assert_eq!(neuron_info.state, NeuronState::Dissolving as i32); assert_eq!( neuron_info.dissolve_delay_seconds, - MAX_DISSOLVE_DELAY_SECONDS + max_dissolve_delay_seconds() ); // Tests for neuron 3. Dissolved. @@ -10760,7 +10775,7 @@ async fn test_known_neurons() { controller: Some(principal(1)), cached_neuron_stake_e8s: 100_000_000, dissolve_state: Some(api::neuron::DissolveState::DissolveDelaySeconds( - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), )), ..Default::default() }, @@ -10773,7 +10788,7 @@ async fn test_known_neurons() { controller: Some(principal(2)), cached_neuron_stake_e8s: 100_000_000, dissolve_state: Some(api::neuron::DissolveState::DissolveDelaySeconds( - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), )), ..Default::default() }, @@ -10786,7 +10801,7 @@ async fn test_known_neurons() { controller: Some(principal(3)), cached_neuron_stake_e8s: 100_000_000_000, dissolve_state: Some(api::neuron::DissolveState::DissolveDelaySeconds( - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), )), ..Default::default() }, @@ -11130,7 +11145,7 @@ lazy_static! { let neuron_base = api::Neuron { cached_neuron_stake_e8s: 100_000 * E8, dissolve_state: Some(api::neuron::DissolveState::DissolveDelaySeconds( - MAX_DISSOLVE_DELAY_SECONDS, + max_dissolve_delay_seconds(), )), ..Default::default() }; diff --git a/rs/nns/governance/tests/merge_neurons.rs b/rs/nns/governance/tests/merge_neurons.rs index 388c528ea7e6..f3ebaffc3b7d 100644 --- a/rs/nns/governance/tests/merge_neurons.rs +++ b/rs/nns/governance/tests/merge_neurons.rs @@ -9,7 +9,7 @@ use ic_base_types::PrincipalId; use ic_nervous_system_common::ONE_YEAR_SECONDS; use ic_nns_common::pb::v1::NeuronId; use ic_nns_governance::{ - governance::MAX_DISSOLVE_DELAY_SECONDS, + governance::max_dissolve_delay_seconds, pb::v1::{ ManageNeuron, manage_neuron::{Command, Merge}, @@ -166,12 +166,12 @@ fn test_merge_neurons_small( n1_stake in 0_u64..50_000, n1_maturity in 0_u64..500_000_000, n1_fees in 0_u64..20_000, - n1_dissolve in 1_u64..MAX_DISSOLVE_DELAY_SECONDS, + n1_dissolve in 1_u64..max_dissolve_delay_seconds(), n1_age in 0_u64..315_360_000, n2_stake in 0_u64..50_000, n2_maturity in 0_u64..500_000_000, n2_fees in 0_u64..20_000, - n2_dissolve in 1_u64..MAX_DISSOLVE_DELAY_SECONDS, + n2_dissolve in 1_u64..max_dissolve_delay_seconds(), n2_age in 0_u64..315_360_000 ) { do_test_merge_neurons( @@ -194,12 +194,12 @@ fn test_merge_neurons_normal( n1_maturity in 0_u64..500_000_000, n1_fees in 0_u64..20_000, - n1_dissolve in 1_u64..MAX_DISSOLVE_DELAY_SECONDS, + n1_dissolve in 1_u64..max_dissolve_delay_seconds(), n1_age in 0_u64..315_360_000, n2_stake in 0_u64..500_000_000, n2_maturity in 0_u64..500_000_000, n2_fees in 0_u64..20_000, - n2_dissolve in 1_u64..MAX_DISSOLVE_DELAY_SECONDS, + n2_dissolve in 1_u64..max_dissolve_delay_seconds(), n2_age in 0_u64..315_360_000 ) { do_test_merge_neurons( diff --git a/rs/nns/integration_tests/src/neuron_following.rs b/rs/nns/integration_tests/src/neuron_following.rs index 2e8612bbed63..fe90481c965a 100644 --- a/rs/nns/integration_tests/src/neuron_following.rs +++ b/rs/nns/integration_tests/src/neuron_following.rs @@ -36,9 +36,9 @@ const VALID_TOPIC: i32 = Topic::ParticipantManagement as i32; const INVALID_TOPIC: i32 = 69420; const PROTOCOAL_CANISTER_MANAGEMENT_TOPIC: i32 = Topic::ProtocolCanisterManagement as i32; const NEURON_MANAGEMENT_TOPIC: i32 = Topic::NeuronManagement as i32; -const VOTING_POWER_NEURON_1: u64 = 1_404_004_106; -const VOTING_POWER_NEURON_2: u64 = 140_400_410; -const VOTING_POWER_NEURON_3: u64 = 14_040_040; +const VOTING_POWER_NEURON_1: u64 = 1_866_016_426; +const VOTING_POWER_NEURON_2: u64 = 186_601_642; +const VOTING_POWER_NEURON_3: u64 = 18_660_163; fn setup_state_machine_with_nns_canisters() -> StateMachine { let state_machine = state_machine_builder_for_nns_tests().build(); @@ -255,7 +255,7 @@ fn vote_propagation_with_following() { Vote::Yes, ); let votes = get_yes_votes(&state_machine, &proposal_id); - assert_eq!(votes, 1_544_404_516); + assert_eq!(votes, 2_052_618_068); let ballot_n1 = check_ballots(&state_machine, &proposal_id, &n1); assert_eq!(ballot_n1, (VOTING_POWER_NEURON_1, Vote::Yes)); @@ -384,12 +384,12 @@ fn vote_propagation_with_following() { let votes = get_yes_votes(&state_machine, &proposal_id); assert_eq!( votes, - 702_002_052 + 701_988_012 + VOTING_POWER_NEURON_2 + VOTING_POWER_NEURON_3 + 933_008_212 + 932_989_552 + VOTING_POWER_NEURON_2 + VOTING_POWER_NEURON_3 ); let ballot_n1 = check_ballots(&state_machine, &proposal_id, &n1); - assert_eq!(ballot_n1, (702_002_052, Vote::Yes)); + assert_eq!(ballot_n1, (933_008_212, Vote::Yes)); let ballot_n1a = check_ballots(&state_machine, &proposal_id, &n1a); - assert_eq!(ballot_n1a, (701_988_012, Vote::Yes)); + assert_eq!(ballot_n1a, (932_989_552, Vote::Yes)); let ballot_n2 = check_ballots(&state_machine, &proposal_id, &n2); assert_eq!(ballot_n2, (VOTING_POWER_NEURON_2, Vote::Yes)); let ballot_n3 = check_ballots(&state_machine, &proposal_id, &n3);