Skip to content

Commit

Permalink
staking: add a min_validator_stake first delegation requirement (#3877
Browse files Browse the repository at this point in the history
)

Close #3853 

This PR:
- removes the `Defined -> Inactive` transition from the delegate action
execution
- ensure that the first delegation to a validator pool must be at least
`min_validator_stake`
- add a `try_precursor_transition` method to the validator state machine
to safely drive transitions in/out of the `Defined` state
  • Loading branch information
erwanor authored Feb 26, 2024
1 parent d88146b commit 9d54fa2
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 148 deletions.
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
use std::sync::Arc;

use anyhow::Result;
use anyhow::{ensure, Result};
use async_trait::async_trait;
use cnidarium::{StateRead, StateWrite};
use cnidarium_component::ActionHandler;
use penumbra_num::Amount;

use crate::{
component::{
validator_handler::{ValidatorDataRead, ValidatorManager},
StateWriteExt as _,
},
event, validator, Delegate, StateReadExt as _,
component::{validator_handler::ValidatorDataRead, StateWriteExt as _},
event,
validator::State::*,
Delegate, StateReadExt as _,
};

#[async_trait]
Expand Down Expand Up @@ -52,7 +52,6 @@ impl ActionHandler for Delegate {
.await?
.ok_or_else(|| anyhow::anyhow!("missing state for validator"))?;

use validator::State::*;
if !validator.enabled {
anyhow::bail!(
"delegations are only allowed to enabled validators, but {} is disabled",
Expand Down Expand Up @@ -96,36 +95,45 @@ impl ActionHandler for Delegate {
}

async fn execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
use crate::validator;
let validator = self.validator_identity;
let unbonded_delegation = self.unbonded_amount;
// This action is executed in two phases:
// 1. We check if the self-delegation requirement is met.
// 2. We queue the delegation for the next epoch.

tracing::debug!(?self, "queuing delegation for next epoch");
state.push_delegation(self.clone());

// When a validator definition is published, it starts in a `Defined` state
// until it gathers enough stake to become `Inactive` and get indexed in the
// validator list.
//
// Unlike other validator state transitions, this one is executed with the
// delegation transaction and not at the end of the epoch. This is because we
// want to avoid having to iterate over all defined validators at all.
// See #2921 for more details.
let validator_state = state
.get_validator_state(&self.validator_identity)
.await?
.ok_or_else(|| anyhow::anyhow!("missing state for validator"))?;

// TODO(erwan): The next PR (#3853) in this sprint changes this logic to require
// an initial delegation that is at least the minimum stake param.
if matches!(validator_state, validator::State::Defined)
&& self.delegation_amount.value()
>= state.get_stake_params().await?.min_validator_stake.value()
{
tracing::debug!(validator_identity = %self.validator_identity, delegation_amount = %self.delegation_amount, "validator has enough stake to transition out of defined state");
state
.set_validator_state(&self.validator_identity, validator::State::Inactive)
.await?;
// When a validator definition is published, it starts in a `Defined` state
// where it is unindexed by the staking module. We transition validator with
// too little stake to the `Defined` state as well. See #2921 for more details.
if validator_state == Defined {
let min_stake = state.get_stake_params().await?.min_validator_stake;
// With #3853, we impose a minimum self-delegation requirement to simplify
// end-epoch handling. The first delegation" to a `Defined` validator must
// be at least `min_validator_stake`.
//
// Note: Validators can be demoted to `Defined` if they have too little stake,
// if we don't check that the pool is empty, we could trap delegations.
let validator_pool_size = state
.get_validator_pool_size(&validator)
.await
.unwrap_or_else(Amount::zero);

if validator_pool_size == Amount::zero() {
ensure!(
unbonded_delegation >= min_stake,
"first delegation to a `Defined` validator must be at least {min_stake}"
);
tracing::debug!(%validator, %unbonded_delegation, "first delegation to validator recorded");
}
}

// We queue the delegation so it can be processed at the epoch boundary.
tracing::debug!(?self, "queuing delegation for next epoch");
state.push_delegation(self.clone());
state.record(event::delegate(self));
Ok(())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,12 @@ impl ActionHandler for validator::Definition {
.is_some();

if validator_exists {
state.update_validator(v.validator.clone()).await.context(
"should be able to update validator during validator definition execution",
)?;
state
.update_validator_definition(v.validator.clone())
.await
.context(
"should be able to update validator during validator definition execution",
)?;
} else {
// This is a new validator definition. We prime the validator's
// rate data with an initial exchange rate of 1:1.
Expand Down
37 changes: 10 additions & 27 deletions crates/core/component/stake/src/component/epoch_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,6 @@ pub trait EpochHandler: StateWriteExt + ConsensusIndexRead {
total_delegations: Amount,
total_undelegations: Amount,
) -> Result<Option<(IdentityKey, FundingStreams, Amount)>> {
let min_validator_stake = self.get_stake_params().await?.min_validator_stake;

let validator = self.get_validator_definition(&validator_identity).await?.ok_or_else(|| {
anyhow::anyhow!("validator (identity={}) is in consensus index but its definition was not found in the JMT", &validator_identity)
})?;
Expand Down Expand Up @@ -329,36 +327,21 @@ pub trait EpochHandler: StateWriteExt + ConsensusIndexRead {
None
};

// We want to know if the validator has enough stake to remain in the consensus set.
// In order to do this, we need to know what is the size of the validator's delegation
// pool in terms of staking tokens (i.e. the unbonded amount).
let delegation_token_denom = DelegationToken::from(&validator.identity_key).denom();
let validator_unbonded_amount =
next_validator_rate.unbonded_amount(delegation_token_supply);

tracing::debug!(
validator_identity = %validator.identity_key,
validator_delegation_pool = ?delegation_token_supply,
validator_unbonded_amount = ?validator_unbonded_amount,
"calculated validator's unbonded amount for the upcoming epoch"
);

if validator_unbonded_amount < min_validator_stake {
tracing::debug!(
validator_identity = %validator.identity_key,
validator_unbonded_amount = ?validator_unbonded_amount,
min_validator_stake = ?min_validator_stake,
"validator's unbonded amount is below the minimum stake threshold, transitioning to defined"
);
self.set_validator_state(&validator.identity_key, validator::State::Defined)
.await?;
}
let final_state = self
.try_precursor_transition(
validator_identity,
validator_state,
&next_validator_rate,
delegation_token_supply,
)
.await;

tracing::debug!(validator_identity = %validator.identity_key,
previous_epoch_validator_rate= ?prev_validator_rate,
next_epoch_validator_rate = ?next_validator_rate,
delegation_denom = ?delegation_token_denom,
?delegation_token_supply,
voting_power = ?voting_power,
final_state = ?final_state,
"validator's end-epoch has been processed");

self.process_validator_pool_state(&validator.identity_key, epoch_to_end)
Expand Down
3 changes: 2 additions & 1 deletion crates/core/component/stake/src/component/stake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ pub trait StateReadExt: StateRead {
/// Gets the stake parameters from the JMT.
async fn get_stake_params(&self) -> Result<StakeParameters> {
self.get(state_key::parameters::key())
.await?
.await
.expect("no deserialization error should happen")
.ok_or_else(|| anyhow!("Missing StakeParameters"))
}

Expand Down
Loading

0 comments on commit 9d54fa2

Please sign in to comment.