Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions rs/nns/governance/canbench/canbench_results.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ benches:
compute_ballots_for_new_proposal_with_stable_neurons:
total:
calls: 1
instructions: 1890848
instructions: 2450000
heap_increase: 0
stable_memory_increase: 256
scopes: {}
Expand Down Expand Up @@ -107,7 +107,7 @@ benches:
neuron_metrics_calculation:
total:
calls: 1
instructions: 3046508
instructions: 3620000
heap_increase: 0
stable_memory_increase: 0
scopes: {}
Expand Down
3 changes: 3 additions & 0 deletions rs/nns/governance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ pub mod timer_tasks;
mod voting;
mod voting_history_store;

/// As of Feb 2026, outside of this crate, this is only used by an integration test.
pub use neuron::dissolve_delay_bonus_multiplier;

/// Limit the amount of work for skipping unneeded data on the wire when parsing Candid.
/// The value of 10_000 follows the Candid recommendation.
const DEFAULT_SKIPPING_QUOTA: usize = 10_000;
Expand Down
12 changes: 8 additions & 4 deletions rs/nns/governance/src/neuron/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
pub mod dissolve_state_and_age;
pub use dissolve_state_and_age::*;
pub mod types;
pub use types::*;
mod dissolve_state_and_age;
mod types;
mod voting_power;

pub(crate) use dissolve_state_and_age::*;
pub(crate) use types::*;

pub use voting_power::*;

fn neuron_stake_e8s(
cached_neuron_stake_e8s: u64,
Expand Down
161 changes: 106 additions & 55 deletions rs/nns/governance/src/neuron/types.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use crate::{
DEFAULT_VOTING_POWER_REFRESHED_TIMESTAMP_SECONDS,
governance::{
LOG_PREFIX, MAX_NEURON_AGE_FOR_AGE_BONUS, MAX_NUM_HOT_KEYS_PER_NEURON,
max_dissolve_delay_seconds,
governance::{LOG_PREFIX, MAX_NUM_HOT_KEYS_PER_NEURON, max_dissolve_delay_seconds},
neuron::{
age_bonus_multiplier, combine_aged_stakes, dissolve_delay_bonus_multiplier,
dissolve_state_and_age::DissolveStateAndAge, neuron_stake_e8s,
},
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,
Expand All @@ -22,7 +22,7 @@ use ic_nervous_system_common::ONE_DAY_SECONDS;
use ic_nns_common::pb::v1::NeuronId;
use ic_nns_governance_api::{self as api, NeuronInfo};
use icp_ledger::Subaccount;
use rust_decimal::{Decimal, RoundingStrategy};
use rust_decimal::{Decimal, RoundingStrategy, prelude::ToPrimitive};
use std::{
collections::{BTreeSet, HashMap},
time::Duration,
Expand Down Expand Up @@ -300,14 +300,78 @@ impl Neuron {
> 0
}

/// How much sway this neuron has when it casts its vote on proposals.
/// If a neuron is "active" (defined below), then this is the same as
/// potential voting power. See the potential_voting_power method. This is
/// the amount of voting power that gets put into ballots.
///
/// In this context, "active" means that the neuron has done one of the
/// following "recently" (where "recently" is defined by
/// start_reducing_voting_power_after_seconds , which as of Feb, 2026, is 6
/// months):
///
/// 1. Voted DIRECTLY, not via following.
/// 2. Set their following.
/// 3. Confirmed their following, aka refershed their voting power.
///
/// Otherwise, if the neuron continues to be "inactive" (as defined above),
/// their deciding voting power decreases linearly with time until it
/// reaches 0 (over a period specified by clear_following_after_seconds,
/// which as of Feb 2026 is 1 month).
///
/// A neuron always has the opportunity to "re-activate".
///
/// In production, it is usually (always?) the case that you also want
/// potential voting power. If you need both, use
/// potential_and_deciding_voting_power instead to avoid re-calculating
/// intermediate results.
pub fn deciding_voting_power(
&self,
voting_power_economics: &VotingPowerEconomics,
now_seconds: u64,
) -> u64 {
// Main inputs to main calculation.
self.potential_and_deciding_voting_power(voting_power_economics, now_seconds)
.1
}

/// Voting power is fundamentally based on the amount staked. From that
/// base, it is boosted by two factors:
///
/// * Its current dissolve delay (i.e. how much time must pass before, the
/// ICP can be taken out of the neuron). The maximum dissolve delay bonus
/// is 3x, which occurs at 2 years. This bonus increases quadratically.
/// Prior to Mission 70, this increased linearly up to 2x over 8 years.
///
/// * How long the neuron has "aged". Basically, the amount of time since it
/// last stopped dissolving. The maximum age bonus is 1.25x, which occurs
/// at 4 years. This bonus increases linearly with age.
///
/// These bonuses are multiplied together, not added. So the maximum total
/// bonus multiplier is 3.75x.
///
/// In production, it is usually (always?) the case that you also want
/// deciding voting power. If you need both, use
/// potential_and_deciding_voting_power instead to avoid re-calculating
/// intermediate results.
pub fn potential_voting_power(&self, now_seconds: u64) -> u64 {
// VotingPowerEconomics is only used when calculating deciding voting
// power, not potential voting power.
let dummy_voting_power_economics = VotingPowerEconomics::default();

self.potential_and_deciding_voting_power(&dummy_voting_power_economics, now_seconds)
.0
}

/// See the potential_voting_power and deciding_voting_power methods.
pub fn potential_and_deciding_voting_power(
&self,
voting_power_economics: &VotingPowerEconomics,
now_seconds: u64,
) -> (u64, u64) {
let potential_voting_power = Decimal::from(self.stake_e8s())
* dissolve_delay_bonus_multiplier(self.dissolve_delay_seconds(now_seconds))
* age_bonus_multiplier(self.age_seconds(now_seconds));

// For DECIDING voting power.
let adjustment_factor: Decimal = {
let time_since_last_refreshed = Duration::from_secs(
now_seconds.saturating_sub(self.voting_power_refreshed_timestamp_seconds),
Expand All @@ -317,61 +381,46 @@ impl Neuron {
.deciding_voting_power_adjustment_factor(time_since_last_refreshed)
};

let potential_voting_power = self.potential_voting_power(now_seconds);

// Main calculation.
let result = adjustment_factor * Decimal::from(potential_voting_power);
let deciding_voting_power = adjustment_factor * potential_voting_power.floor();

// Convert potential voting power to u64 (from Decimal).
let potential_voting_power = potential_voting_power
// This clamping really shouldn't have an effect, because the most
// that the bonus multipliers can do is multiply stake by 3 * 1.25,
// and if anyone to have a sufficiently large stake amount to exceed
// u64::MAX, it almost certainly means that our record of their
// stake is bogus as a result of some bug.
.clamp(Decimal::from(0), Decimal::from(u64::MAX))
// Truncates. (Since we are non-negative, truncation is equivalent
// to rounding down.)
.to_u64()
.unwrap_or_else(|| {
// Because of clamping (above), it is not possible for this to
// be reached, but ofc, Rust is not smart enough to know that
// (and rust_decimal might contain a bug).
println!(
"{LOG_PREFIX}ERROR: Unable to convert {potential_voting_power:?} \
to u64. Using 0 instead."
);
self.stake_e8s()
});

// Convert (back) to u64. The particular type of rounding used here does
// not matter to us very much, because we are not for example
// apportioning (where rounding down is best), nor anything like that.
let result = result.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven);
u64::try_from(result).unwrap_or_else(|err| {
let deciding_voting_power =
deciding_voting_power.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven);
let deciding_voting_power = u64::try_from(deciding_voting_power).unwrap_or_else(|err| {
// Log and fall back to potential voting power. Assuming
// adjustment_factor is in [0, 1], I see no way this can happen.
println!(
"{}ERROR: Unable to convert deciding voting power {} * {} back to u64: {:?}",
LOG_PREFIX, adjustment_factor, potential_voting_power, err,
);
potential_voting_power
})
}
});

/// Return the voting power of this neuron.
///
/// The voting power is the stake of the neuron modified by a
/// bonus of up to 100% depending on the dissolve delay, with
/// the maximum bonus of 100% received at an 8 year dissolve
/// delay. The voting power is further modified by the age of
/// the neuron giving up to 25% bonus after four years.
pub fn potential_voting_power(&self, now_seconds: u64) -> u64 {
// We compute the stake adjustments in u128.
let stake = self.stake_e8s() as u128;
// Dissolve delay is capped to eight years, but we cap it
// again here to make sure, e.g., if this changes in the
// future.
let d = std::cmp::min(
self.dissolve_delay_seconds(now_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));
// Sanity check.
assert!(d_stake <= 2 * stake);
// The voting power is also a function of the age of the
// neuron, giving a bonus of up to 25% at the four year mark.
let a = std::cmp::min(self.age_seconds(now_seconds), MAX_NEURON_AGE_FOR_AGE_BONUS) as u128;
let ad_stake = d_stake.saturating_add(
(d_stake.saturating_mul(a)) / (4 * MAX_NEURON_AGE_FOR_AGE_BONUS as u128),
);
// Final stake 'ad_stake' is at most 5/4 of the 'd_stake'.
assert!(ad_stake <= (5 * d_stake) / 4);
// The final voting power is the stake adjusted by both age
// and dissolve delay. If the stake is is greater than
// u64::MAX divided by 2.5, the voting power may actually not
// fit in a u64.
std::cmp::min(ad_stake, u64::MAX as u128) as u64
(potential_voting_power, deciding_voting_power)
}

/// Get the recent ballots, with most recent ballots first
Expand Down Expand Up @@ -865,8 +914,8 @@ impl Neuron {
}

let visibility = Some(self.visibility() as i32);
let deciding_voting_power = self.deciding_voting_power(voting_power_economics, now_seconds);
let potential_voting_power = self.potential_voting_power(now_seconds);
let (potential_voting_power, deciding_voting_power) =
self.potential_and_deciding_voting_power(voting_power_economics, now_seconds);
let known_neuron_data = if multi_query {
None
} else {
Expand Down Expand Up @@ -1288,9 +1337,11 @@ impl Neuron {
multi_query: bool,
) -> api::Neuron {
let visibility = Some(self.visibility() as i32);
let deciding_voting_power =
Some(self.deciding_voting_power(voting_power_economics, now_seconds));
let potential_voting_power = Some(self.potential_voting_power(now_seconds));
let (potential_voting_power, deciding_voting_power) =
self.potential_and_deciding_voting_power(voting_power_economics, now_seconds);
let potential_voting_power = Some(potential_voting_power);
let deciding_voting_power = Some(deciding_voting_power);

let recent_ballots = self.sorted_recent_ballots();

let Neuron {
Expand Down
1 change: 1 addition & 0 deletions rs/nns/governance/src/neuron/types/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::*;
use crate::{
governance::max_dissolve_delay_seconds,
neuron::{DissolveStateAndAge, NeuronBuilder},
pb::v1::{
self as pb, VotingPowerEconomics,
Expand Down
35 changes: 35 additions & 0 deletions rs/nns/governance/src/neuron/voting_power.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use crate::{
governance::{MAX_NEURON_AGE_FOR_AGE_BONUS, max_dissolve_delay_seconds},
is_mission_70_voting_rewards_enabled,
};
use rust_decimal::Decimal;

/// Currently, only used by an integration test.
pub fn dissolve_delay_bonus_multiplier(dissolve_delay_seconds: u64) -> Decimal {
let max_dissolve_delay_seconds = max_dissolve_delay_seconds();

let dissolve_delay_seconds = dissolve_delay_seconds.clamp(0, max_dissolve_delay_seconds);

// t is (clamped) dissolve delay in units of max dissolve delay, so 0.0 <= t <= 1.0.
let t = Decimal::from(dissolve_delay_seconds) / Decimal::from(max_dissolve_delay_seconds);

(if is_mission_70_voting_rewards_enabled() {
Decimal::from(2) * t * t
} else {
t
}) + Decimal::from(1)
}

pub(crate) fn age_bonus_multiplier(age_seconds: u64) -> Decimal {
let age_seconds = Decimal::from(age_seconds.clamp(0, MAX_NEURON_AGE_FOR_AGE_BONUS));

// t is (clamped) age in units of max age, so its value is from 0.0 to 1.0
let t = age_seconds / Decimal::from(MAX_NEURON_AGE_FOR_AGE_BONUS);

// 0.25 * t + 1
t / Decimal::from(4) + Decimal::from(1)
}

#[cfg(test)]
#[path = "voting_power_tests.rs"]
mod tests;
Loading
Loading