From b429bc7b379a85520b45255c0828750938362639 Mon Sep 17 00:00:00 2001 From: Erwan Or Date: Tue, 12 Mar 2024 17:51:34 -0400 Subject: [PATCH] staking: use block height to enforce unbonding delay (#3923) Close #3738, this PR makes the unbonding mechanism based on block delay rather than epochs. In practice, this means that a user will wait X blocks for a validator pool to unbond (rather than Y epochs). To achieve this, this PR: - defines `StakeParamater::unbonding_delay` measured in blocks - parameterize unbonding tokens based on a `start_height` - deprecate `start_epoch_index` fields in delegate/claim actions - strip `epoch_index` from validator `RateData`s - generalize the unbonding token denom format: `2.013718unbonding_start_at_1540_penumbravalid1` (vs. `_epoch_XXX`) Important point about the mechanism: we bind tokens to the starting height of the epoch that they belong to. This let us avoid binding transactions to specific block heights. --- Cargo.lock | 1 + crates/bin/pcli/src/command/query/chain.rs | 37 +++++-- crates/bin/pcli/src/command/tx.rs | 57 ++++++++-- crates/bin/pcli/tests/proof.rs | 4 +- crates/bin/pd/src/testnet/generate.rs | 6 +- .../src/gen/proto_descriptor.bin.no_lfs | Bin 98383 -> 95074 bytes crates/core/app/src/params/change.rs | 8 +- crates/core/asset/src/asset/registry.rs | 6 +- .../src/component/shielded_pool.rs | 2 - .../src/component/action_handler/delegate.rs | 101 +++++++++-------- .../component/action_handler/undelegate.rs | 42 ++++---- .../action_handler/undelegate_claim.rs | 49 ++++++--- .../action_handler/validator_definition.rs | 14 +-- .../stake/src/component/epoch_handler.rs | 2 +- .../component/stake/src/component/stake.rs | 8 +- .../validator_handler/validator_manager.rs | 54 +++++----- .../validator_handler/validator_store.rs | 24 +++-- crates/core/component/stake/src/params.rs | 12 ++- crates/core/component/stake/src/rate.rs | 18 ++-- .../component/stake/src/unbonding_token.rs | 18 ++-- crates/core/component/stake/src/undelegate.rs | 25 +++-- .../stake/src/undelegate_claim/action.rs | 10 +- .../stake/src/undelegate_claim/plan.rs | 14 +-- .../component/stake/src/validator/bonding.rs | 22 ++-- .../gen/penumbra.core.component.stake.v1.rs | 20 ++++ .../penumbra.core.component.stake.v1.serde.rs | 102 ++++++++++++++++++ crates/proto/src/gen/penumbra.view.v1.rs | 7 ++ .../proto/src/gen/penumbra.view.v1.serde.rs | 38 +++++++ .../proto/src/gen/proto_descriptor.bin.no_lfs | Bin 370246 -> 368349 bytes crates/view/src/planner.rs | 19 +++- crates/view/src/service.rs | 31 +++++- crates/wallet/Cargo.toml | 1 + crates/wallet/src/plan.rs | 4 +- .../core/component/stake/v1/stake.proto | 22 ++-- proto/penumbra/penumbra/view/v1/view.proto | 8 +- 35 files changed, 543 insertions(+), 243 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28da7a9d89..e073c7207d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5809,6 +5809,7 @@ dependencies = [ "penumbra-keys", "penumbra-num", "penumbra-proto", + "penumbra-sct", "penumbra-stake", "penumbra-tct", "penumbra-transaction", diff --git a/crates/bin/pcli/src/command/query/chain.rs b/crates/bin/pcli/src/command/query/chain.rs index 0ce35e9f15..0a6ef66d22 100644 --- a/crates/bin/pcli/src/command/query/chain.rs +++ b/crates/bin/pcli/src/command/query/chain.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result}; use comfy_table::{presets, Table}; use futures::TryStreamExt; use penumbra_app::params::AppParameters; +use penumbra_num::fixpoint::U128x128; use penumbra_proto::{ core::app::v1::{ query_service_client::QueryServiceClient as AppQueryServiceClient, AppParametersRequest, @@ -16,7 +17,7 @@ use penumbra_proto::{ tendermint_proxy_service_client::TendermintProxyServiceClient, GetStatusRequest, }, }; -use penumbra_stake::validator; +use penumbra_stake::{validator, BPS_SQUARED_SCALING_FACTOR}; // TODO: remove this subcommand and merge into `pcli q` @@ -56,6 +57,18 @@ impl ChainCmd { .ok_or_else(|| anyhow::anyhow!("empty AppParametersResponse message"))? .try_into()?; + fn scale_rate(rate_bps_sq: u64) -> U128x128 { + let rate_bps_sq = U128x128::from(rate_bps_sq); + (rate_bps_sq / *BPS_SQUARED_SCALING_FACTOR).expect("non zero denominator") + } + + fn display_rate_percent(rate_bps_sq: u64) -> String { + let rate = scale_rate(rate_bps_sq); + let hundred = U128x128::from(100u128); + let rate_pct: U128x128 = (rate * hundred).expect("rate is around 1"); + format!("{}%", rate_pct) + } + println!("Chain Parameters:"); let mut table = Table::new(); table.load_preset(presets::NOTHING); @@ -63,28 +76,32 @@ impl ChainCmd { .set_header(vec!["", ""]) .add_row(vec!["Chain ID", ¶ms.chain_id]) .add_row(vec![ - "Epoch Duration", + "Epoch Duration (# of blocks)", &format!("{}", params.sct_params.epoch_duration), ]) .add_row(vec![ - "Unbonding Epochs", - &format!("{}", params.stake_params.unbonding_epochs), + "Unbonding delay (# of blocks)", + &format!("{}", params.stake_params.unbonding_delay), + ]) + .add_row(vec![ + "Minimum Validator Stake (upenumbra)", + &format!("{}", params.stake_params.min_validator_stake), ]) .add_row(vec![ "Active Validator Limit", &format!("{}", params.stake_params.active_validator_limit), ]) .add_row(vec![ - "Base Reward Rate (bps^2)", - &format!("{}", params.stake_params.base_reward_rate), + "Base Reward Rate", + &display_rate_percent(params.stake_params.base_reward_rate), ]) .add_row(vec![ - "Slashing Penalty (Misbehavior) (bps^2)", - &format!("{}", params.stake_params.slashing_penalty_misbehavior), + "Slashing Penalty (Misbehavior)", + &display_rate_percent(params.stake_params.slashing_penalty_misbehavior), ]) .add_row(vec![ - "Slashing Penalty (Downtime) (bps^2)", - &format!("{}", params.stake_params.slashing_penalty_downtime), + "Slashing Penalty (Downtime)", + &display_rate_percent(params.stake_params.slashing_penalty_downtime), ]) .add_row(vec![ "Signed Blocks Window (blocks)", diff --git a/crates/bin/pcli/src/command/tx.rs b/crates/bin/pcli/src/command/tx.rs index 6c6a2166cb..3e391e66f8 100644 --- a/crates/bin/pcli/src/command/tx.rs +++ b/crates/bin/pcli/src/command/tx.rs @@ -546,19 +546,31 @@ impl TxCmd { let to = to.parse::()?; - let mut client = StakeQueryServiceClient::new(app.pd_channel().await?); - let rate_data: RateData = client + let mut stake_client = StakeQueryServiceClient::new(app.pd_channel().await?); + let rate_data: RateData = stake_client .current_validator_rate(tonic::Request::new(to.into())) .await? .into_inner() .try_into()?; + let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?); + let latest_sync_height = app.view().status().await?.full_sync_height; + let epoch = sct_client + .epoch_by_height(EpochByHeightRequest { + height: latest_sync_height, + }) + .await? + .into_inner() + .epoch + .expect("epoch must be available") + .into(); + let mut planner = Planner::new(OsRng); planner .set_gas_prices(gas_prices) .set_fee_tier((*fee_tier).into()); let plan = planner - .delegate(unbonded_amount, rate_data) + .delegate(epoch, unbonded_amount, rate_data) .plan(app.view(), AddressIndex::new(*source)) .await .context("can't plan delegation")?; @@ -588,20 +600,32 @@ impl TxCmd { let from = delegation_token.validator(); - let mut client = StakeQueryServiceClient::new(app.pd_channel().await?); - let rate_data: RateData = client + let mut stake_client = StakeQueryServiceClient::new(app.pd_channel().await?); + let rate_data: RateData = stake_client .current_validator_rate(tonic::Request::new(from.into())) .await? .into_inner() .try_into()?; + let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?); + let latest_sync_height = app.view().status().await?.full_sync_height; + let epoch = sct_client + .epoch_by_height(EpochByHeightRequest { + height: latest_sync_height, + }) + .await? + .into_inner() + .epoch + .expect("epoch must be available") + .into(); + let mut planner = Planner::new(OsRng); planner .set_gas_prices(gas_prices) .set_fee_tier((*fee_tier).into()); let plan = planner - .undelegate(delegation_value.amount, rate_data) + .undelegate(epoch, delegation_value.amount, rate_data) .plan( app.view .as_mut() @@ -651,15 +675,26 @@ impl TxCmd { }) { println!("claiming {}", token.denom().default_unit()); + let validator_identity = token.validator(); - let start_epoch_index = token.start_epoch_index(); + let unbonding_start_height = token.unbonding_start_height(); let end_epoch_index = current_epoch.index; - let mut client = StakeQueryServiceClient::new(channel.clone()); - let penalty: Penalty = client + let mut sct_client = SctQueryServiceClient::new(channel.clone()); + let epoch_start = sct_client + .epoch_by_height(EpochByHeightRequest { + height: unbonding_start_height, + }) + .await? + .into_inner() + .epoch + .context("unable to get epoch for unbonding start height")?; + + let mut stake_client = StakeQueryServiceClient::new(channel.clone()); + let penalty: Penalty = stake_client .validator_penalty(tonic::Request::new(ValidatorPenaltyRequest { identity_key: Some(validator_identity.into()), - start_epoch_index, + start_epoch_index: epoch_start.index, end_epoch_index, })) .await? @@ -685,7 +720,7 @@ impl TxCmd { let plan = planner .undelegate_claim(UndelegateClaimPlan { validator_identity, - start_epoch_index, + unbonding_start_height, penalty, unbonding_amount, balance_blinding: Fr::rand(&mut OsRng), diff --git a/crates/bin/pcli/tests/proof.rs b/crates/bin/pcli/tests/proof.rs index 1e7e029f65..04373da145 100644 --- a/crates/bin/pcli/tests/proof.rs +++ b/crates/bin/pcli/tests/proof.rs @@ -426,8 +426,8 @@ fn undelegate_claim_parameters_vs_current_undelegate_claim_circuit() { let validator_identity = IdentityKey((&sk).into()); let unbonding_amount = Amount::from(value1_amount); - let start_epoch_index = 1; - let unbonding_token = UnbondingToken::new(validator_identity, start_epoch_index); + let start_height = 1; + let unbonding_token = UnbondingToken::new(validator_identity, start_height); let unbonding_id = unbonding_token.id(); let penalty = Penalty::from_bps_squared(penalty_amount); let balance = penalty.balance_for_claim(unbonding_id, unbonding_amount); diff --git a/crates/bin/pd/src/testnet/generate.rs b/crates/bin/pd/src/testnet/generate.rs index 3bb15f1bb2..418164b640 100644 --- a/crates/bin/pd/src/testnet/generate.rs +++ b/crates/bin/pd/src/testnet/generate.rs @@ -184,7 +184,7 @@ impl TestnetConfig { validators: Vec, active_validator_limit: Option, epoch_duration: Option, - unbonding_epochs: Option, + unbonding_delay: Option, proposal_voting_blocks: Option, ) -> anyhow::Result { let default_gov_params = penumbra_governance::params::GovernanceParameters::default(); @@ -205,8 +205,8 @@ impl TestnetConfig { stake_params: StakeParameters { active_validator_limit: active_validator_limit .unwrap_or(default_app_params.stake_params.active_validator_limit), - unbonding_epochs: unbonding_epochs - .unwrap_or(default_app_params.stake_params.unbonding_epochs), + unbonding_delay: unbonding_delay + .unwrap_or(default_app_params.stake_params.unbonding_delay), ..Default::default() }, }, diff --git a/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs b/crates/cnidarium/src/gen/proto_descriptor.bin.no_lfs index 67d781308dc15349cc1633e0986b9be4d49fda1f..d50e1bd8818eb8b4d521b590c16703d42bad4e23 100644 GIT binary patch delta 19801 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 QsWnq;W;8wa^^V#931P9$$^ZZW diff --git a/crates/core/app/src/params/change.rs b/crates/core/app/src/params/change.rs index 4808c40ca4..c84e6e00a4 100644 --- a/crates/core/app/src/params/change.rs +++ b/crates/core/app/src/params/change.rs @@ -60,7 +60,6 @@ impl AppParameters { }, stake_params: StakeParameters { - unbonding_epochs: _, active_validator_limit, base_reward_rate: _, slashing_penalty_misbehavior: _, @@ -68,6 +67,7 @@ impl AppParameters { signed_blocks_window_len, missed_blocks_maximum: _, min_validator_stake: _, + unbonding_delay: _, }, // IMPORTANT: Don't use `..` here! We want to ensure every single field is verified! } = self; @@ -148,7 +148,6 @@ impl AppParameters { }, stake_params: StakeParameters { - unbonding_epochs, active_validator_limit, base_reward_rate, slashing_penalty_misbehavior, @@ -156,6 +155,7 @@ impl AppParameters { signed_blocks_window_len, missed_blocks_maximum, min_validator_stake, + unbonding_delay, }, // IMPORTANT: Don't use `..` here! We want to ensure every single field is verified! } = self; @@ -167,8 +167,8 @@ impl AppParameters { "epoch duration must be at least one block", ), ( - *unbonding_epochs >= 1, - "unbonding must take at least one epoch", + *unbonding_delay >= epoch_duration * 2 + 1, + "unbonding must take at least two epochs", ), ( *active_validator_limit > 3, diff --git a/crates/core/asset/src/asset/registry.rs b/crates/core/asset/src/asset/registry.rs index a6aaca603d..7d9a56f481 100644 --- a/crates/core/asset/src/asset/registry.rs +++ b/crates/core/asset/src/asset/registry.rs @@ -356,10 +356,10 @@ pub static REGISTRY: Lazy = Lazy::new(|| { // Note: this regex must be in sync with UnbondingToken::try_from // and VALIDATOR_IDENTITY_BECH32_PREFIX in the penumbra-stake crate // TODO: this doesn't restrict the length of the bech32 encoding - "^uunbonding_(?Pepoch_(?P[0-9]+)_until_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", + "^uunbonding_(?Pstart_at_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", &[ - "^unbonding_(?Pepoch_(?P[0-9]+)_until_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", - "^munbonding_(?Pepoch_(?P[0-9]+)_until_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", + "^unbonding_(?Pstart_at_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", + "^munbonding_(?Pstart_at_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$", ], (|data: &str| { assert!(!data.is_empty()); diff --git a/crates/core/component/shielded-pool/src/component/shielded_pool.rs b/crates/core/component/shielded-pool/src/component/shielded_pool.rs index ca4e52efff..58782f46e0 100644 --- a/crates/core/component/shielded-pool/src/component/shielded_pool.rs +++ b/crates/core/component/shielded-pool/src/component/shielded_pool.rs @@ -116,8 +116,6 @@ pub trait StateWriteExt: StateWrite + StateReadExt { /// Writes the current FMD parameters to the JMT. fn put_current_fmd_parameters(&mut self, params: fmd::Parameters) { - // MERGEBLOCK(erwan): read through the update mechanism for FMD params - // do we need to flag that shielded pool params were updated? self.put(fmd::state_key::parameters::current().into(), params) } diff --git a/crates/core/component/stake/src/component/action_handler/delegate.rs b/crates/core/component/stake/src/component/action_handler/delegate.rs index dfae403c18..8db537414c 100644 --- a/crates/core/component/stake/src/component/action_handler/delegate.rs +++ b/crates/core/component/stake/src/component/action_handler/delegate.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use cnidarium::StateWrite; use cnidarium_component::ActionHandler; use penumbra_num::Amount; +use penumbra_sct::component::clock::EpochRead; use crate::{ component::{validator_handler::ValidatorDataRead, StateWriteExt as _}, @@ -25,25 +26,49 @@ impl ActionHandler for Delegate { // move some of them back. let d = self; - let next_rate_data = state + + // We check if the rate data is for the current epoch to provide a helpful + // error message if there is a mismatch. + let current_epoch = state.get_current_epoch().await?; + ensure!( + d.epoch_index == current_epoch.index, + "delegation was prepared for epoch {} but the current epoch is {}", + d.epoch_index, + current_epoch.index + ); + + // For delegations, we enforce correct computation (with rounding) + // of the *delegation amount based on the unbonded amount*, because + // users (should be) starting with the amount of unbonded stake they + // wish to delegate, and computing the amount of delegation tokens + // they receive. + // + // The direction of the computation matters because the computation + // involves rounding, so while both + // + // (unbonded amount, rates) -> delegation amount + // (delegation amount, rates) -> unbonded amount + // + // should give approximately the same results, they may not give + // exactly the same results. + let validator_rate = state .get_validator_rate(&d.validator_identity) .await? .ok_or_else(|| anyhow::anyhow!("unknown validator identity {}", d.validator_identity))? .clone(); - // Check whether the epoch is correct first, to give a more helpful - // error message if it's wrong. - if d.epoch_index != next_rate_data.epoch_index { - anyhow::bail!( - "delegation was prepared for epoch {} but the next epoch is {}", - d.epoch_index, - next_rate_data.epoch_index - ); - } + let expected_delegation_amount = validator_rate.delegation_amount(d.unbonded_amount); - // Check whether the delegation is allowed - // The delegation is allowed if: - // - the validator definition is "enabled" by the operator + ensure!( + expected_delegation_amount == d.delegation_amount, + "given {} unbonded stake, expected {} delegation tokens but description produces {}", + d.unbonded_amount, + expected_delegation_amount, + d.delegation_amount, + ); + + // The delegation is only allowed if both conditions are met: + // - the validator definition is `enabled` by the operator // - the validator is not jailed or tombstoned let validator = state .get_validator_definition(&d.validator_identity) @@ -54,53 +79,27 @@ impl ActionHandler for Delegate { .await? .ok_or_else(|| anyhow::anyhow!("missing state for validator"))?; - if !validator.enabled { - anyhow::bail!( - "delegations are only allowed to enabled validators, but {} is disabled", - d.validator_identity, - ); - } - if !matches!(validator_state, Defined | Inactive | Active) { - anyhow::bail!( - "delegations are only allowed to active or inactive validators, but {} is in state {:?}", - d.validator_identity, - validator_state, - ); - } + ensure!( + validator.enabled, + "delegations are only allowed to enabled validators, but {} is disabled", + d.validator_identity, + ); - // For delegations, we enforce correct computation (with rounding) - // of the *delegation amount based on the unbonded amount*, because - // users (should be) starting with the amount of unbonded stake they - // wish to delegate, and computing the amount of delegation tokens - // they receive. - // - // The direction of the computation matters because the computation - // involves rounding, so while both - // - // (unbonded amount, rates) -> delegation amount - // (delegation amount, rates) -> unbonded amount - // - // should give approximately the same results, they may not give - // exactly the same results. - let expected_delegation_amount = next_rate_data.delegation_amount(d.unbonded_amount); - - if expected_delegation_amount != d.delegation_amount { - anyhow::bail!( - "given {} unbonded stake, expected {} delegation tokens but description produces {}", - d.unbonded_amount, - expected_delegation_amount, - d.delegation_amount - ); - } + ensure!( + matches!(validator_state, Defined | Inactive | Active), + "delegations are only allowed to active or inactive validators, but {} is in state {:?}", + d.validator_identity, + validator_state, + ); // (end of former check_historical checks) 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. - let validator_state = state .get_validator_state(&self.validator_identity) .await? diff --git a/crates/core/component/stake/src/component/action_handler/undelegate.rs b/crates/core/component/stake/src/component/action_handler/undelegate.rs index a885ae6633..b3c8983ca5 100644 --- a/crates/core/component/stake/src/component/action_handler/undelegate.rs +++ b/crates/core/component/stake/src/component/action_handler/undelegate.rs @@ -1,6 +1,7 @@ use anyhow::{ensure, Result}; use async_trait::async_trait; use cnidarium::StateWrite; +use penumbra_sct::component::clock::EpochRead; use penumbra_shielded_pool::component::SupplyWrite; use crate::{ @@ -20,24 +21,18 @@ impl ActionHandler for Undelegate { // These checks all formerly happened in the `check_historical` method, // if profiling shows that they cause a bottleneck we could (CAREFULLY) // move some of them back. - let u = self; - let rate_data = state - .get_validator_rate(&u.validator_identity) - .await? - .ok_or_else(|| { - anyhow::anyhow!("unknown validator identity {}", u.validator_identity) - })?; - // Check whether the start epoch is correct first, to give a more helpful - // error message if it's wrong. - if u.start_epoch_index != rate_data.epoch_index { - anyhow::bail!( - "undelegation was prepared for next epoch {} but the next epoch is {}", - u.start_epoch_index, - rate_data.epoch_index - ); - } + // Check that the undelegation was prepared for the current epoch. + // This let us provide a more helpful error message if an epoch boundary was crossed. + // And it ensures that the unbonding delay is enforced correctly. + let current_epoch = state.get_current_epoch().await?; + ensure!( + u.from_epoch == current_epoch, + "undelegation was prepared for epoch {} but the current epoch is {}", + u.from_epoch.index, + current_epoch.index + ); // For undelegations, we enforce correct computation (with rounding) // of the *unbonded amount based on the delegation amount*, because @@ -53,6 +48,12 @@ impl ActionHandler for Undelegate { // // should give approximately the same results, they may not give // exactly the same results. + let rate_data = state + .get_validator_rate(&u.validator_identity) + .await? + .ok_or_else(|| { + anyhow::anyhow!("unknown validator identity {}", u.validator_identity) + })?; let expected_unbonded_amount = rate_data.unbonded_amount(u.delegation_amount); ensure!( @@ -62,15 +63,16 @@ impl ActionHandler for Undelegate { expected_unbonded_amount, ); - // (end of former check_historical impl) + /* ----- execution ------ */ - tracing::debug!(?self, "queuing undelegation for next epoch"); - state.push_undelegation(self.clone()); // Register the undelegation's denom, so clients can look it up later. state .register_denom(&self.unbonding_token().denom()) .await?; - // TODO: should we be tracking changes to token supply here or in end_epoch? + + tracing::debug!(?self, "queuing undelegation for next epoch"); + state.push_undelegation(self.clone()); + state.record(event::undelegate(self)); Ok(()) diff --git a/crates/core/component/stake/src/component/action_handler/undelegate_claim.rs b/crates/core/component/stake/src/component/action_handler/undelegate_claim.rs index 0a903191d4..0bdc2ca24f 100644 --- a/crates/core/component/stake/src/component/action_handler/undelegate_claim.rs +++ b/crates/core/component/stake/src/component/action_handler/undelegate_claim.rs @@ -14,8 +14,11 @@ use crate::{component::action_handler::ActionHandler, UnbondingToken}; impl ActionHandler for UndelegateClaim { type CheckStatelessContext = (); async fn check_stateless(&self, _context: ()) -> Result<()> { - let unbonding_id = - UnbondingToken::new(self.body.validator_identity, self.body.start_epoch_index).id(); + let unbonding_id = UnbondingToken::new( + self.body.validator_identity, + self.body.unbonding_start_height, + ) + .id(); self.proof.verify( &CONVERT_PROOF_VERIFICATION_KEY, @@ -34,27 +37,41 @@ impl ActionHandler for UndelegateClaim { // if profiling shows that they cause a bottleneck we could (CAREFULLY) // move some of them back. - // If the validator delegation pool is bonded, or unbonding, check that enough epochs - // have elapsed to claim the unbonding tokens: - let current_epoch = state.get_current_epoch().await?; - let allowed_unbonding_epoch = state - .compute_unbonding_epoch(&self.body.validator_identity, self.body.start_epoch_index) + // If the validator delegation pool is bonded, or unbonding, we must + // check that the unbonding delay (measured in blocks) has elapsed. + let current_height = state.get_block_height().await?; + + // Check if the unbonding height has been reached, it will always be the case if + // the validator delegation pool is unbonded. + let allowed_unbonding_height = state + .compute_unbonding_height( + &self.body.validator_identity, + self.body.unbonding_start_height, + ) .await?; + let wait_blocks = allowed_unbonding_height.saturating_sub(current_height); + ensure!( - current_epoch.index >= allowed_unbonding_epoch, - "cannot claim unbonding tokens before the end epoch (current epoch: {}, unbonding epoch: {})", - current_epoch.index, - allowed_unbonding_epoch + current_height >= allowed_unbonding_height, + "cannot claim unbonding tokens before height {} (currently at {}, wait {} blocks)", + allowed_unbonding_height, + current_height, + wait_blocks ); - // Compute the penalty for the epoch range [start_epoch_index, unbonding_epoch], and check + let unbonding_epoch_start = state + .get_epoch_by_height(self.body.unbonding_start_height) + .await?; + let unbonding_epoch_end = state.get_epoch_by_height(allowed_unbonding_height).await?; + + // Compute the penalty for the epoch range [unbonding_epoch_start, unbonding_epoch_end], and check // that it matches the penalty in the claim. let expected_penalty = state .compounded_penalty_over_range( &self.body.validator_identity, - self.body.start_epoch_index, - allowed_unbonding_epoch, + unbonding_epoch_start.index, + unbonding_epoch_end.index, ) .await?; @@ -63,11 +80,9 @@ impl ActionHandler for UndelegateClaim { "penalty does not match expected penalty" ); - // (end of former check_historical impl) - + /* ---------- execution ----------- */ // No state changes here - this action just converts one token to another - // TODO: where should we be tracking token supply changes? Ok(()) } } diff --git a/crates/core/component/stake/src/component/action_handler/validator_definition.rs b/crates/core/component/stake/src/component/action_handler/validator_definition.rs index 315c89a3fb..af5b8a2748 100644 --- a/crates/core/component/stake/src/component/action_handler/validator_definition.rs +++ b/crates/core/component/stake/src/component/action_handler/validator_definition.rs @@ -1,7 +1,6 @@ use anyhow::{ensure, Context, Result}; use async_trait::async_trait; use cnidarium::StateWrite; -use penumbra_sct::component::clock::EpochRead; use penumbra_proto::DomainType; @@ -59,10 +58,9 @@ impl ActionHandler for validator::Definition { } async fn check_and_execute(&self, mut state: S) -> Result<()> { - // These checks all formerly happened in the `check_historical` method, + // These checks all formerly happened in the `check_stateful` method, // if profiling shows that they cause a bottleneck we could (CAREFULLY) // move some of them back. - let v = self; // Check that the sequence numbers of the updated validators is correct... @@ -99,7 +97,6 @@ impl ActionHandler for validator::Definition { // 2. If we submit a validator update to CometBFT that // includes duplicate consensus keys, CometBFT gets confused // and hangs. - // Note: This is currently vulnerable to a TOCTOU hazard. Tracked in #3858. ensure!( ck_owner.identity_key == v.validator.identity_key, "consensus key {:?} is already in use by validator {}", @@ -108,15 +105,9 @@ impl ActionHandler for validator::Definition { ); } - // (end of former check_historical impl) - + /* ------------ execution ----------- */ let v = self; - let current_epoch = state - .get_current_epoch() - .await - .context("should be able to get current epoch during validator definition execution")?; - let validator_exists = state .get_validator_definition(&v.validator.identity_key) .await @@ -137,7 +128,6 @@ impl ActionHandler for validator::Definition { let initial_rate_data = RateData { identity_key: validator_key, - epoch_index: current_epoch.index, validator_reward_rate: 0u128.into(), validator_exchange_rate: 1_0000_0000u128.into(), // 1 represented as 1e8 }; diff --git a/crates/core/component/stake/src/component/epoch_handler.rs b/crates/core/component/stake/src/component/epoch_handler.rs index 24dd3b6fd3..0c725db62a 100644 --- a/crates/core/component/stake/src/component/epoch_handler.rs +++ b/crates/core/component/stake/src/component/epoch_handler.rs @@ -343,7 +343,7 @@ pub trait EpochHandler: StateWriteExt + ConsensusIndexRead { final_state = ?final_state, "validator's end-epoch has been processed"); - self.process_validator_pool_state(&validator.identity_key, epoch_to_end) + self.process_validator_pool_state(&validator.identity_key, epoch_to_end.start_height) .await.map_err(|e| { tracing::error!(?e, validator_identity = %validator.identity_key, "failed to process validator pool state"); e diff --git a/crates/core/component/stake/src/component/stake.rs b/crates/core/component/stake/src/component/stake.rs index 7b9e317e06..aabb371aa2 100644 --- a/crates/core/component/stake/src/component/stake.rs +++ b/crates/core/component/stake/src/component/stake.rs @@ -365,10 +365,12 @@ pub trait SlashingData: StateRead { async fn compounded_penalty_over_range( &self, id: &IdentityKey, - start: u64, - end: u64, + epoch_index_start: u64, + epoch_index_end: u64, ) -> Result { - let range = self.get_penalty_for_range(id, start, end).await; + let range = self + .get_penalty_for_range(id, epoch_index_start, epoch_index_end) + .await; let compounded_penalty = Self::compute_compounded_penalty(range); Ok(compounded_penalty) } diff --git a/crates/core/component/stake/src/component/validator_handler/validator_manager.rs b/crates/core/component/stake/src/component/validator_handler/validator_manager.rs index 7925c879ba..468f2cadb8 100644 --- a/crates/core/component/stake/src/component/validator_handler/validator_manager.rs +++ b/crates/core/component/stake/src/component/validator_handler/validator_manager.rs @@ -13,10 +13,7 @@ use anyhow::Result; use async_trait::async_trait; use futures::StreamExt as _; use penumbra_num::Amount; -use penumbra_sct::{ - component::clock::{EpochManager, EpochRead}, - epoch::Epoch, -}; +use penumbra_sct::component::clock::{EpochManager, EpochRead}; use penumbra_shielded_pool::component::{SupplyRead as _, SupplyWrite}; use sha2::{Digest as _, Sha256}; use tendermint::abci::types::{CommitInfo, Misbehavior}; @@ -134,9 +131,13 @@ pub trait ValidatorManager: StateWrite { ) -> Result<()> { let validator_state_path = state_key::validators::state::by_id(identity_key); - // We use the current epoch index to compute the unbonding epoch for the validator, - // when necessary. - let current_epoch = self.get_current_epoch().await?; + // Using the start height of the current epoch let us do block based unbonding delays without + // requiring to bind actions to a specific block height (instead they bind to a whole epoch). + let unbonding_start_height = { + // We scope it strictly to avoid accidentally using the wrong height. + let current_height = self.get_block_height().await?; + self.get_epoch_by_height(current_height).await?.start_height + }; tracing::debug!("trying to execute a state transition"); @@ -187,8 +188,8 @@ pub trait ValidatorManager: StateWrite { self.set_validator_bonding_state( identity_key, Unbonding { - unbonds_at_epoch: self - .compute_unbonding_epoch(identity_key, current_epoch.index) + unbonds_at_height: self + .compute_unbonding_height(identity_key, unbonding_start_height) .await?, }, ); @@ -205,17 +206,19 @@ pub trait ValidatorManager: StateWrite { self.record_slashing_penalty(identity_key, Penalty::from_bps_squared(penalty)) .await; - // The validator's delegation pool begins unbonding. - let unbonds_at_epoch = self - .compute_unbonding_epoch(identity_key, current_epoch.index) + // The validator's delegation pool begins unbonding. Jailed + // validators are not unbonded immediately, because they need to + // be held accountable for byzantine behavior for the entire + // unbonding period. + let unbonds_at_height = self + .compute_unbonding_height(identity_key, unbonding_start_height) .await?; - // Note: `Jailed` validators are not unbonded immediately, so that they - // can be held accountable for byzantine behavior. - self.set_validator_bonding_state(identity_key, Unbonding { unbonds_at_epoch }); + self.set_validator_bonding_state(identity_key, Unbonding { unbonds_at_height }); - tracing::debug!(penalty, unbonds_at_epoch, "jailed validator"); + tracing::debug!(penalty, unbonds_at_height, "jailed validator"); } + (Defined | Disabled | Inactive | Active | Jailed, Tombstoned) => { // When we detect byzantine misbehavior from a validator, we: // 1. Record the maximum slashing penalty for the corresponding pool @@ -367,9 +370,8 @@ pub trait ValidatorManager: StateWrite { ) -> Result<()> { let initial_validator_rate = RateData { identity_key: validator.identity_key.clone(), - epoch_index: genesis_base_rate.epoch_index, - validator_reward_rate: 0u128.into(), - validator_exchange_rate: 1_0000_0000u128.into(), // 1 represented as 1e8 + validator_reward_rate: genesis_base_rate.base_reward_rate.clone(), + validator_exchange_rate: genesis_base_rate.base_exchange_rate.clone(), }; // The initial allocations to the validator are specified in `genesis_allocations`. // In this case, the validator's delegation pool size is exactly its allocation @@ -574,22 +576,22 @@ pub trait ValidatorManager: StateWrite { async fn process_validator_pool_state( &mut self, validator_identity: &IdentityKey, - at_epoch: Epoch, + from_height: u64, ) -> Result<()> { let pool_state = self.get_validator_bonding_state(validator_identity).await; // If the pool is already unbonded, this will return the current epoch. - let unbonding_epoch_target = self - .compute_unbonding_epoch(validator_identity, at_epoch.index) + let allowed_unbonding_height = self + .compute_unbonding_height(validator_identity, from_height) .await?; tracing::debug!( - validator_identity = %validator_identity, ?pool_state, - ?unbonding_epoch_target, - "processing validator pool state"); + ?allowed_unbonding_height, + "processing validator pool state" + ); - if at_epoch.index >= unbonding_epoch_target { + if from_height >= allowed_unbonding_height { // The validator's delegation pool has finished unbonding, so we // transition it to the Unbonded state. let _ = self diff --git a/crates/core/component/stake/src/component/validator_handler/validator_store.rs b/crates/core/component/stake/src/component/validator_handler/validator_store.rs index 2d6a196db8..95a3f7f56b 100644 --- a/crates/core/component/stake/src/component/validator_handler/validator_store.rs +++ b/crates/core/component/stake/src/component/validator_handler/validator_store.rs @@ -142,12 +142,16 @@ pub trait ValidatorDataRead: StateRead { } } - /// Compute the unbonding epoch for an undelegation initiated at `starting_epoch`. - /// If the pool is unbonded, or already unbonding, the `starting_epoch` is ignored. + /// Compute the unbonding epoch for an undelegation initiated at `unbonding_height_start`. + /// If the pool is unbonded, or already unbonding, the `unbonding_height_start` is ignored. /// /// This can be used to check if the undelegation is allowed, or to compute the /// epoch at which a delegation pool will be unbonded. - async fn compute_unbonding_epoch(&self, id: &IdentityKey, starting_epoch: u64) -> Result { + async fn compute_unbonding_height( + &self, + id: &IdentityKey, + unbonding_start_height: u64, + ) -> Result { let Some(val_bonding_state) = self.get_validator_bonding_state(id).await else { anyhow::bail!( "validator bonding state not tracked (validator_identity={})", @@ -155,19 +159,19 @@ pub trait ValidatorDataRead: StateRead { ) }; - let min_epoch_delay = self.get_stake_params().await?.unbonding_epochs; + let min_block_delay = self.get_stake_params().await?.unbonding_delay; - let upper_bound_epoch = starting_epoch.saturating_add(min_epoch_delay); + let upper_bound_height = unbonding_start_height.saturating_add(min_block_delay); - let unbonding_epoch = match val_bonding_state { - Bonded => upper_bound_epoch, + let unbonding_height = match val_bonding_state { + Bonded => upper_bound_height, // When the minimum delay parameter changes, an unbonding validator may // have a delay that is larger than the new minimum delay. In this case, - Unbonding { unbonds_at_epoch } => unbonds_at_epoch.min(upper_bound_epoch), - Unbonded => starting_epoch, + Unbonding { unbonds_at_height } => unbonds_at_height.min(upper_bound_height), + Unbonded => unbonding_start_height, }; - Ok(unbonding_epoch) + Ok(unbonding_height) } // TODO(erwan): we pull the entire validator definition instead of tracking diff --git a/crates/core/component/stake/src/params.rs b/crates/core/component/stake/src/params.rs index 0ae64af281..4a8b424edf 100644 --- a/crates/core/component/stake/src/params.rs +++ b/crates/core/component/stake/src/params.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(try_from = "pb::StakeParameters", into = "pb::StakeParameters")] pub struct StakeParameters { - /// The number of epochs that must pass before a validator can be unbonded. - pub unbonding_epochs: u64, + /// The number of blocks to wait before a validator can unbond their stake. + pub unbonding_delay: u64, /// The number of validators allowed in the consensus set (Active state). pub active_validator_limit: u64, /// The base reward rate, expressed in basis points of basis points @@ -33,7 +33,6 @@ impl TryFrom for StakeParameters { fn try_from(msg: pb::StakeParameters) -> anyhow::Result { Ok(StakeParameters { - unbonding_epochs: msg.unbonding_epochs, active_validator_limit: msg.active_validator_limit, slashing_penalty_downtime: msg.slashing_penalty_downtime, slashing_penalty_misbehavior: msg.slashing_penalty_misbehavior, @@ -44,14 +43,16 @@ impl TryFrom for StakeParameters { .min_validator_stake .ok_or_else(|| anyhow::anyhow!("missing min_validator_stake"))? .try_into()?, + unbonding_delay: msg.unbonding_delay, }) } } impl From for pb::StakeParameters { + #[allow(deprecated)] fn from(params: StakeParameters) -> Self { pb::StakeParameters { - unbonding_epochs: params.unbonding_epochs, + unbonding_epochs: 0, active_validator_limit: params.active_validator_limit, signed_blocks_window_len: params.signed_blocks_window_len, missed_blocks_maximum: params.missed_blocks_maximum, @@ -59,6 +60,7 @@ impl From for pb::StakeParameters { slashing_penalty_misbehavior: params.slashing_penalty_misbehavior, base_reward_rate: params.base_reward_rate, min_validator_stake: Some(params.min_validator_stake.into()), + unbonding_delay: params.unbonding_delay, } } } @@ -67,7 +69,7 @@ impl From for pb::StakeParameters { impl Default for StakeParameters { fn default() -> Self { Self { - unbonding_epochs: 2, + unbonding_delay: 719 * 3 + 1, active_validator_limit: 80, // Copied from cosmos hub signed_blocks_window_len: 10000, diff --git a/crates/core/component/stake/src/rate.rs b/crates/core/component/stake/src/rate.rs index 4cc47b51e1..b1149c4a90 100644 --- a/crates/core/component/stake/src/rate.rs +++ b/crates/core/component/stake/src/rate.rs @@ -4,6 +4,7 @@ use penumbra_num::fixpoint::U128x128; use penumbra_num::Amount; use penumbra_proto::core::component::stake::v1::CurrentValidatorRateResponse; use penumbra_proto::{penumbra::core::component::stake::v1 as pb, DomainType}; +use penumbra_sct::epoch::Epoch; use serde::{Deserialize, Serialize}; use crate::{validator::State, FundingStream, IdentityKey}; @@ -15,8 +16,6 @@ use crate::{Delegate, Penalty, Undelegate, BPS_SQUARED_SCALING_FACTOR}; pub struct RateData { /// The validator's identity key. pub identity_key: IdentityKey, - /// The index of the epoch for which this rate is valid. - pub epoch_index: u64, /// The validator-specific reward rate. pub validator_reward_rate: Amount, /// The validator-specific exchange rate. @@ -113,7 +112,6 @@ impl RateData { RateData { identity_key: previous_rate.identity_key.clone(), - epoch_index: previous_rate.epoch_index + 1, validator_reward_rate: next_validator_reward_rate, validator_exchange_rate: next_validator_exchange_rate, } @@ -122,7 +120,6 @@ impl RateData { // the next epoch's rate is set to the current rate. RateData { identity_key: previous_rate.identity_key.clone(), - epoch_index: previous_rate.epoch_index + 1, validator_reward_rate: previous_rate.validator_reward_rate, validator_exchange_rate: previous_rate.validator_exchange_rate, } @@ -235,10 +232,10 @@ impl RateData { /// Uses this `RateData` to build a `Delegate` transaction action that /// delegates `unbonded_amount` of the staking token. - pub fn build_delegate(&self, unbonded_amount: Amount) -> Delegate { + pub fn build_delegate(&self, epoch: Epoch, unbonded_amount: Amount) -> Delegate { Delegate { delegation_amount: self.delegation_amount(unbonded_amount), - epoch_index: self.epoch_index, + epoch_index: epoch.index, unbonded_amount, validator_identity: self.identity_key.clone(), } @@ -246,9 +243,9 @@ impl RateData { /// Uses this `RateData` to build an `Undelegate` transaction action that /// undelegates `delegation_amount` of the validator's delegation tokens. - pub fn build_undelegate(&self, delegation_amount: Amount) -> Undelegate { + pub fn build_undelegate(&self, start_epoch: Epoch, delegation_amount: Amount) -> Undelegate { Undelegate { - start_epoch_index: self.epoch_index, + from_epoch: start_epoch, delegation_amount, unbonded_amount: self.unbonded_amount(delegation_amount), validator_identity: self.identity_key.clone(), @@ -312,10 +309,11 @@ impl DomainType for RateData { } impl From for pb::RateData { + #[allow(deprecated)] fn from(v: RateData) -> Self { pb::RateData { identity_key: Some(v.identity_key.into()), - epoch_index: v.epoch_index, + epoch_index: 0, validator_reward_rate: Some(v.validator_reward_rate.into()), validator_exchange_rate: Some(v.validator_exchange_rate.into()), } @@ -330,7 +328,6 @@ impl TryFrom for RateData { .identity_key .ok_or_else(|| anyhow::anyhow!("missing identity key"))? .try_into()?, - epoch_index: v.epoch_index, validator_reward_rate: v .validator_reward_rate .ok_or_else(|| anyhow::anyhow!("empty validator reward rate in RateData message"))? @@ -408,7 +405,6 @@ mod tests { let rate_data = RateData { identity_key: ik, - epoch_index: 0, validator_reward_rate: 1_0000_0000u128.into(), validator_exchange_rate: 2_0000_0000u128.into(), }; diff --git a/crates/core/component/stake/src/unbonding_token.rs b/crates/core/component/stake/src/unbonding_token.rs index a7d2ce55e2..d3344f7f39 100644 --- a/crates/core/component/stake/src/unbonding_token.rs +++ b/crates/core/component/stake/src/unbonding_token.rs @@ -13,23 +13,23 @@ use crate::IdentityKey; /// which unbonding began. pub struct UnbondingToken { validator_identity: IdentityKey, - start_epoch_index: u64, + unbonding_start_height: u64, base_denom: asset::Metadata, } impl UnbondingToken { - pub fn new(validator_identity: IdentityKey, start_epoch_index: u64) -> Self { + pub fn new(validator_identity: IdentityKey, unbonding_start_height: u64) -> Self { // This format string needs to be in sync with the asset registry let base_denom = asset::REGISTRY .parse_denom(&format!( // "uu" is not a typo, these are micro-unbonding tokens - "uunbonding_epoch_{start_epoch_index}_{validator_identity}" + "uunbonding_start_at_{unbonding_start_height}_{validator_identity}" )) .expect("base denom format is valid"); UnbondingToken { validator_identity, base_denom, - start_epoch_index, + unbonding_start_height, } } @@ -53,8 +53,8 @@ impl UnbondingToken { self.validator_identity.clone() } - pub fn start_epoch_index(&self) -> u64 { - self.start_epoch_index + pub fn unbonding_start_height(&self) -> u64 { + self.unbonding_start_height } } @@ -68,7 +68,7 @@ impl TryFrom for UnbondingToken { // and VALIDATOR_IDENTITY_BECH32_PREFIX // The data capture group is used by asset::REGISTRY let captures = - Regex::new("^uunbonding_(?Pepoch_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$") + Regex::new("^uunbonding_(?Pstart_at_(?P[0-9]+)_(?Ppenumbravalid1[a-zA-HJ-NP-Z0-9]+))$") .expect("regex is valid") .captures(base_string.as_ref()) .ok_or_else(|| { @@ -84,7 +84,7 @@ impl TryFrom for UnbondingToken { .as_str() .parse()?; - let start_epoch_index = captures + let unbonding_start_height = captures .name("start") .expect("start is a named capture") .as_str() @@ -93,7 +93,7 @@ impl TryFrom for UnbondingToken { Ok(Self { base_denom, validator_identity, - start_epoch_index, + unbonding_start_height, }) } } diff --git a/crates/core/component/stake/src/undelegate.rs b/crates/core/component/stake/src/undelegate.rs index 5ccdd8dfab..34e0102fbc 100644 --- a/crates/core/component/stake/src/undelegate.rs +++ b/crates/core/component/stake/src/undelegate.rs @@ -1,6 +1,7 @@ use penumbra_asset::{Balance, Value}; use penumbra_num::Amount; use penumbra_proto::{penumbra::core::component::stake::v1 as pb, DomainType}; +use penumbra_sct::epoch::Epoch; use penumbra_txhash::{EffectHash, EffectingData}; use serde::{Deserialize, Serialize}; @@ -12,9 +13,9 @@ use crate::{DelegationToken, IdentityKey, UnbondingToken}; pub struct Undelegate { /// The identity key of the validator to undelegate from. pub validator_identity: IdentityKey, - /// The index of the epoch in which this undelegation was performed. + /// The epoch at which the undelegation was performed. /// The undelegation takes effect after the unbonding period. - pub start_epoch_index: u64, + pub from_epoch: Epoch, /// The amount to undelegate, in units of unbonding tokens. pub unbonded_amount: Amount, /// The amount of delegation tokens produced by this action. @@ -50,7 +51,10 @@ impl Undelegate { } pub fn unbonding_token(&self) -> UnbondingToken { - UnbondingToken::new(self.validator_identity.clone(), self.start_epoch_index) + UnbondingToken::new( + self.validator_identity.clone(), + self.from_epoch.start_height, + ) } pub fn delegation_token(&self) -> DelegationToken { @@ -63,12 +67,14 @@ impl DomainType for Undelegate { } impl From for pb::Undelegate { + #[allow(deprecated)] fn from(d: Undelegate) -> Self { pb::Undelegate { validator_identity: Some(d.validator_identity.into()), - start_epoch_index: d.start_epoch_index, unbonded_amount: Some(d.unbonded_amount.into()), delegation_amount: Some(d.delegation_amount.into()), + from_epoch: Some(d.from_epoch.into()), + start_epoch_index: 0, } } } @@ -79,16 +85,19 @@ impl TryFrom for Undelegate { Ok(Self { validator_identity: d .validator_identity - .ok_or_else(|| anyhow::anyhow!("missing validator identity"))? + .ok_or_else(|| anyhow::anyhow!("missing validator_identity"))? + .try_into()?, + from_epoch: d + .from_epoch + .ok_or_else(|| anyhow::anyhow!("missing from_epoch"))? .try_into()?, - start_epoch_index: d.start_epoch_index, unbonded_amount: d .unbonded_amount - .ok_or_else(|| anyhow::anyhow!("missing unbonded amount"))? + .ok_or_else(|| anyhow::anyhow!("missing unbonded_amount"))? .try_into()?, delegation_amount: d .delegation_amount - .ok_or_else(|| anyhow::anyhow!("missing delegation amount"))? + .ok_or_else(|| anyhow::anyhow!("missing delegation_amount"))? .try_into()?, }) } diff --git a/crates/core/component/stake/src/undelegate_claim/action.rs b/crates/core/component/stake/src/undelegate_claim/action.rs index 225c93bd31..a27cd5c488 100644 --- a/crates/core/component/stake/src/undelegate_claim/action.rs +++ b/crates/core/component/stake/src/undelegate_claim/action.rs @@ -10,12 +10,12 @@ use crate::{IdentityKey, Penalty, UndelegateClaimProof}; pub struct UndelegateClaimBody { /// The identity key of the validator to undelegate from. pub validator_identity: IdentityKey, - /// The epoch in which unbonding began, used to verify the penalty. - pub start_epoch_index: u64, /// The penalty applied to undelegation, in bps^2. pub penalty: Penalty, /// The action's contribution to the transaction's value balance. pub balance_commitment: balance::Commitment, + /// The height at which unbonding started. + pub unbonding_start_height: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -43,12 +43,14 @@ impl DomainType for UndelegateClaimBody { } impl From for pb::UndelegateClaimBody { + #[allow(deprecated)] fn from(d: UndelegateClaimBody) -> Self { pb::UndelegateClaimBody { validator_identity: Some(d.validator_identity.into()), - start_epoch_index: d.start_epoch_index, + start_epoch_index: 0, penalty: Some(d.penalty.into()), balance_commitment: Some(d.balance_commitment.into()), + unbonding_start_height: d.unbonding_start_height, } } } @@ -61,7 +63,6 @@ impl TryFrom for UndelegateClaimBody { .validator_identity .ok_or_else(|| anyhow::anyhow!("missing validator identity"))? .try_into()?, - start_epoch_index: d.start_epoch_index, penalty: d .penalty .ok_or_else(|| anyhow::anyhow!("missing penalty"))? @@ -70,6 +71,7 @@ impl TryFrom for UndelegateClaimBody { .balance_commitment .ok_or_else(|| anyhow::anyhow!("missing balance_commitment"))? .try_into()?, + unbonding_start_height: d.unbonding_start_height, }) } } diff --git a/crates/core/component/stake/src/undelegate_claim/plan.rs b/crates/core/component/stake/src/undelegate_claim/plan.rs index 1991d34820..2cf28b1acb 100644 --- a/crates/core/component/stake/src/undelegate_claim/plan.rs +++ b/crates/core/component/stake/src/undelegate_claim/plan.rs @@ -18,8 +18,6 @@ use super::UndelegateClaimProofPublic; pub struct UndelegateClaimPlan { /// The identity key of the validator to undelegate from. pub validator_identity: IdentityKey, - /// The epoch in which unbonding began, used to verify the penalty. - pub start_epoch_index: u64, /// The penalty applied to undelegation, in bps^2. pub penalty: Penalty, /// The amount of unbonding tokens to claim. This is a bare number because its denom is determined by the preceding data. @@ -31,6 +29,8 @@ pub struct UndelegateClaimPlan { pub proof_blinding_r: Fq, /// The second blinding factor used for generating the ZK proof. pub proof_blinding_s: Fq, + /// The height at which unbonding began, used to verify the penalty and unbonding delay. + pub unbonding_start_height: u64, } impl UndelegateClaimPlan { @@ -46,7 +46,7 @@ impl UndelegateClaimPlan { pub fn undelegate_claim_body(&self) -> UndelegateClaimBody { UndelegateClaimBody { validator_identity: self.validator_identity, - start_epoch_index: self.start_epoch_index, + unbonding_start_height: self.unbonding_start_height, penalty: self.penalty, balance_commitment: self.balance().commit(self.balance_blinding), } @@ -72,7 +72,7 @@ impl UndelegateClaimPlan { } pub fn unbonding_token(&self) -> UnbondingToken { - UnbondingToken::new(self.validator_identity, self.start_epoch_index) + UnbondingToken::new(self.validator_identity, self.unbonding_start_height) } pub fn unbonding_id(&self) -> asset::Id { @@ -94,12 +94,14 @@ impl DomainType for UndelegateClaimPlan { } impl From for pb::UndelegateClaimPlan { + #[allow(deprecated)] fn from(msg: UndelegateClaimPlan) -> Self { Self { validator_identity: Some(msg.validator_identity.into()), - start_epoch_index: msg.start_epoch_index, + start_epoch_index: 0, penalty: Some(msg.penalty.into()), unbonding_amount: Some(msg.unbonding_amount.into()), + unbonding_start_height: msg.unbonding_start_height, balance_blinding: msg.balance_blinding.to_bytes().to_vec(), proof_blinding_r: msg.proof_blinding_r.to_bytes().to_vec(), proof_blinding_s: msg.proof_blinding_s.to_bytes().to_vec(), @@ -124,7 +126,6 @@ impl TryFrom for UndelegateClaimPlan { .validator_identity .ok_or_else(|| anyhow::anyhow!("missing validator_identity"))? .try_into()?, - start_epoch_index: msg.start_epoch_index, penalty: msg .penalty .ok_or_else(|| anyhow::anyhow!("missing penalty"))? @@ -141,6 +142,7 @@ impl TryFrom for UndelegateClaimPlan { .map_err(|_| anyhow::anyhow!("invalid balance_blinding"))?, proof_blinding_r: Fq::from_bytes(proof_blinding_r_bytes)?, proof_blinding_s: Fq::from_bytes(proof_blinding_s_bytes)?, + unbonding_start_height: msg.unbonding_start_height, }) } } diff --git a/crates/core/component/stake/src/validator/bonding.rs b/crates/core/component/stake/src/validator/bonding.rs index 30e13ebdd2..b810b7b28e 100644 --- a/crates/core/component/stake/src/validator/bonding.rs +++ b/crates/core/component/stake/src/validator/bonding.rs @@ -18,8 +18,8 @@ pub enum State { Unbonded, /// The validator has been removed from the active set. /// - /// All delegations to the validator will be unbonded at `unbonds_at_epoch`. - Unbonding { unbonds_at_epoch: u64 }, + /// All delegations to the validator will be unbonded at `unbonds_at_height`. + Unbonding { unbonds_at_height: u64 }, } impl std::fmt::Display for State { @@ -27,10 +27,8 @@ impl std::fmt::Display for State { match self { State::Bonded => write!(f, "Bonded"), State::Unbonded => write!(f, "Unbonded"), - State::Unbonding { - unbonds_at_epoch: unbonding_epoch, - } => { - write!(f, "Unbonding (end epoch: {unbonding_epoch})") + State::Unbonding { unbonds_at_height } => { + write!(f, "Unbonding (until height: {unbonds_at_height})") } } } @@ -41,6 +39,7 @@ impl DomainType for State { } impl From for pb::BondingState { + #[allow(deprecated)] fn from(v: State) -> Self { pb::BondingState { state: match v { @@ -48,8 +47,9 @@ impl From for pb::BondingState { State::Unbonded => pb::bonding_state::BondingStateEnum::Unbonded as i32, State::Unbonding { .. } => pb::bonding_state::BondingStateEnum::Unbonding as i32, }, - unbonds_at_epoch: match v { - State::Unbonding { unbonds_at_epoch } => unbonds_at_epoch, + unbonds_at_epoch: 0, + unbonds_at_height: match v { + State::Unbonding { unbonds_at_height } => unbonds_at_height, _ => 0, }, } @@ -66,12 +66,12 @@ impl TryFrom for State { pb::bonding_state::BondingStateEnum::Bonded => Ok(State::Bonded), pb::bonding_state::BondingStateEnum::Unbonded => Ok(State::Unbonded), pb::bonding_state::BondingStateEnum::Unbonding => { - let unbonds_at_epoch = if v.unbonds_at_epoch > 0 { - v.unbonds_at_epoch + let unbonds_at_height = if v.unbonds_at_height > 0 { + v.unbonds_at_height } else { anyhow::bail!("unbonding epoch should be set for unbonding state") }; - Ok(State::Unbonding { unbonds_at_epoch }) + Ok(State::Unbonding { unbonds_at_height }) } pb::bonding_state::BondingStateEnum::Unspecified => { Err(anyhow::anyhow!("unspecified bonding state!")) diff --git a/crates/proto/src/gen/penumbra.core.component.stake.v1.rs b/crates/proto/src/gen/penumbra.core.component.stake.v1.rs index a51b0732a8..7f5393ea64 100644 --- a/crates/proto/src/gen/penumbra.core.component.stake.v1.rs +++ b/crates/proto/src/gen/penumbra.core.component.stake.v1.rs @@ -143,6 +143,7 @@ impl ::prost::Name for FundingStream { pub struct RateData { #[prost(message, optional, tag = "1")] pub identity_key: ::core::option::Option, + #[deprecated] #[prost(uint64, tag = "2")] pub epoch_index: u64, #[prost(message, optional, tag = "4")] @@ -205,8 +206,11 @@ impl ::prost::Name for ValidatorStatus { pub struct BondingState { #[prost(enumeration = "bonding_state::BondingStateEnum", tag = "1")] pub state: i32, + #[deprecated] #[prost(uint64, tag = "2")] pub unbonds_at_epoch: u64, + #[prost(uint64, tag = "3")] + pub unbonds_at_height: u64, } /// Nested message and enum types in `BondingState`. pub mod bonding_state { @@ -406,6 +410,7 @@ pub struct Undelegate { super::super::super::keys::v1::IdentityKey, >, /// The index of the epoch in which this undelegation was performed. + #[deprecated] #[prost(uint64, tag = "2")] pub start_epoch_index: u64, /// The amount to undelegate, in units of unbonding tokens. @@ -418,6 +423,9 @@ pub struct Undelegate { /// stateless verification that the transaction is internally consistent. #[prost(message, optional, tag = "4")] pub delegation_amount: ::core::option::Option, + /// The epoch in which this delegation was performed. + #[prost(message, optional, tag = "5")] + pub from_epoch: ::core::option::Option, } impl ::prost::Name for Undelegate { const NAME: &'static str = "Undelegate"; @@ -452,6 +460,7 @@ pub struct UndelegateClaimBody { super::super::super::keys::v1::IdentityKey, >, /// The epoch in which unbonding began, used to verify the penalty. + #[deprecated] #[prost(uint64, tag = "2")] pub start_epoch_index: u64, /// The penalty applied to undelegation, in bps^2 (10e-8). @@ -463,6 +472,9 @@ pub struct UndelegateClaimBody { pub balance_commitment: ::core::option::Option< super::super::super::asset::v1::BalanceCommitment, >, + /// / The starting height of the epoch during which unbonding began. + #[prost(uint64, tag = "5")] + pub unbonding_start_height: u64, } impl ::prost::Name for UndelegateClaimBody { const NAME: &'static str = "UndelegateClaimBody"; @@ -480,6 +492,7 @@ pub struct UndelegateClaimPlan { super::super::super::keys::v1::IdentityKey, >, /// The epoch in which unbonding began, used to verify the penalty. + #[deprecated] #[prost(uint64, tag = "2")] pub start_epoch_index: u64, /// The penalty applied to undelegation, in bps^2 (10e-8). @@ -499,6 +512,9 @@ pub struct UndelegateClaimPlan { /// The second blinding factor to use for the ZK undelegate claim proof. #[prost(bytes = "vec", tag = "8")] pub proof_blinding_s: ::prost::alloc::vec::Vec, + /// The height during which unbonding began. + #[prost(uint64, tag = "9")] + pub unbonding_start_height: u64, } impl ::prost::Name for UndelegateClaimPlan { const NAME: &'static str = "UndelegateClaimPlan"; @@ -688,6 +704,7 @@ impl ::prost::Name for CurrentValidatorRateResponse { #[derive(Clone, PartialEq, ::prost::Message)] pub struct StakeParameters { /// The number of epochs an unbonding note for before being released. + #[deprecated] #[prost(uint64, tag = "1")] pub unbonding_epochs: u64, /// The maximum number of validators in the consensus set. @@ -713,6 +730,9 @@ pub struct StakeParameters { pub min_validator_stake: ::core::option::Option< super::super::super::num::v1::Amount, >, + /// The number of blocks that must elapse before an unbonding note can be claimed. + #[prost(uint64, tag = "9")] + pub unbonding_delay: u64, } impl ::prost::Name for StakeParameters { const NAME: &'static str = "StakeParameters"; diff --git a/crates/proto/src/gen/penumbra.core.component.stake.v1.serde.rs b/crates/proto/src/gen/penumbra.core.component.stake.v1.serde.rs index be98b0d909..639ecfbd62 100644 --- a/crates/proto/src/gen/penumbra.core.component.stake.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.core.component.stake.v1.serde.rs @@ -147,6 +147,9 @@ impl serde::Serialize for BondingState { if self.unbonds_at_epoch != 0 { len += 1; } + if self.unbonds_at_height != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.stake.v1.BondingState", len)?; if self.state != 0 { let v = bonding_state::BondingStateEnum::try_from(self.state) @@ -157,6 +160,10 @@ impl serde::Serialize for BondingState { #[allow(clippy::needless_borrow)] struct_ser.serialize_field("unbondsAtEpoch", ToString::to_string(&self.unbonds_at_epoch).as_str())?; } + if self.unbonds_at_height != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("unbondsAtHeight", ToString::to_string(&self.unbonds_at_height).as_str())?; + } struct_ser.end() } } @@ -170,12 +177,15 @@ impl<'de> serde::Deserialize<'de> for BondingState { "state", "unbonds_at_epoch", "unbondsAtEpoch", + "unbonds_at_height", + "unbondsAtHeight", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { State, UnbondsAtEpoch, + UnbondsAtHeight, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -200,6 +210,7 @@ impl<'de> serde::Deserialize<'de> for BondingState { match value { "state" => Ok(GeneratedField::State), "unbondsAtEpoch" | "unbonds_at_epoch" => Ok(GeneratedField::UnbondsAtEpoch), + "unbondsAtHeight" | "unbonds_at_height" => Ok(GeneratedField::UnbondsAtHeight), _ => Ok(GeneratedField::__SkipField__), } } @@ -221,6 +232,7 @@ impl<'de> serde::Deserialize<'de> for BondingState { { let mut state__ = None; let mut unbonds_at_epoch__ = None; + let mut unbonds_at_height__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::State => { @@ -237,6 +249,14 @@ impl<'de> serde::Deserialize<'de> for BondingState { Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) ; } + GeneratedField::UnbondsAtHeight => { + if unbonds_at_height__.is_some() { + return Err(serde::de::Error::duplicate_field("unbondsAtHeight")); + } + unbonds_at_height__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -245,6 +265,7 @@ impl<'de> serde::Deserialize<'de> for BondingState { Ok(BondingState { state: state__.unwrap_or_default(), unbonds_at_epoch: unbonds_at_epoch__.unwrap_or_default(), + unbonds_at_height: unbonds_at_height__.unwrap_or_default(), }) } } @@ -1604,6 +1625,9 @@ impl serde::Serialize for StakeParameters { if self.min_validator_stake.is_some() { len += 1; } + if self.unbonding_delay != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.stake.v1.StakeParameters", len)?; if self.unbonding_epochs != 0 { #[allow(clippy::needless_borrow)] @@ -1636,6 +1660,10 @@ impl serde::Serialize for StakeParameters { if let Some(v) = self.min_validator_stake.as_ref() { struct_ser.serialize_field("minValidatorStake", v)?; } + if self.unbonding_delay != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("unbondingDelay", ToString::to_string(&self.unbonding_delay).as_str())?; + } struct_ser.end() } } @@ -1662,6 +1690,8 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { "missedBlocksMaximum", "min_validator_stake", "minValidatorStake", + "unbonding_delay", + "unbondingDelay", ]; #[allow(clippy::enum_variant_names)] @@ -1674,6 +1704,7 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { SignedBlocksWindowLen, MissedBlocksMaximum, MinValidatorStake, + UnbondingDelay, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -1704,6 +1735,7 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { "signedBlocksWindowLen" | "signed_blocks_window_len" => Ok(GeneratedField::SignedBlocksWindowLen), "missedBlocksMaximum" | "missed_blocks_maximum" => Ok(GeneratedField::MissedBlocksMaximum), "minValidatorStake" | "min_validator_stake" => Ok(GeneratedField::MinValidatorStake), + "unbondingDelay" | "unbonding_delay" => Ok(GeneratedField::UnbondingDelay), _ => Ok(GeneratedField::__SkipField__), } } @@ -1731,6 +1763,7 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { let mut signed_blocks_window_len__ = None; let mut missed_blocks_maximum__ = None; let mut min_validator_stake__ = None; + let mut unbonding_delay__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::UnbondingEpochs => { @@ -1795,6 +1828,14 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { } min_validator_stake__ = map_.next_value()?; } + GeneratedField::UnbondingDelay => { + if unbonding_delay__.is_some() { + return Err(serde::de::Error::duplicate_field("unbondingDelay")); + } + unbonding_delay__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -1809,6 +1850,7 @@ impl<'de> serde::Deserialize<'de> for StakeParameters { signed_blocks_window_len: signed_blocks_window_len__.unwrap_or_default(), missed_blocks_maximum: missed_blocks_maximum__.unwrap_or_default(), min_validator_stake: min_validator_stake__, + unbonding_delay: unbonding_delay__.unwrap_or_default(), }) } } @@ -1835,6 +1877,9 @@ impl serde::Serialize for Undelegate { if self.delegation_amount.is_some() { len += 1; } + if self.from_epoch.is_some() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.stake.v1.Undelegate", len)?; if let Some(v) = self.validator_identity.as_ref() { struct_ser.serialize_field("validatorIdentity", v)?; @@ -1849,6 +1894,9 @@ impl serde::Serialize for Undelegate { if let Some(v) = self.delegation_amount.as_ref() { struct_ser.serialize_field("delegationAmount", v)?; } + if let Some(v) = self.from_epoch.as_ref() { + struct_ser.serialize_field("fromEpoch", v)?; + } struct_ser.end() } } @@ -1867,6 +1915,8 @@ impl<'de> serde::Deserialize<'de> for Undelegate { "unbondedAmount", "delegation_amount", "delegationAmount", + "from_epoch", + "fromEpoch", ]; #[allow(clippy::enum_variant_names)] @@ -1875,6 +1925,7 @@ impl<'de> serde::Deserialize<'de> for Undelegate { StartEpochIndex, UnbondedAmount, DelegationAmount, + FromEpoch, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -1901,6 +1952,7 @@ impl<'de> serde::Deserialize<'de> for Undelegate { "startEpochIndex" | "start_epoch_index" => Ok(GeneratedField::StartEpochIndex), "unbondedAmount" | "unbonded_amount" => Ok(GeneratedField::UnbondedAmount), "delegationAmount" | "delegation_amount" => Ok(GeneratedField::DelegationAmount), + "fromEpoch" | "from_epoch" => Ok(GeneratedField::FromEpoch), _ => Ok(GeneratedField::__SkipField__), } } @@ -1924,6 +1976,7 @@ impl<'de> serde::Deserialize<'de> for Undelegate { let mut start_epoch_index__ = None; let mut unbonded_amount__ = None; let mut delegation_amount__ = None; + let mut from_epoch__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ValidatorIdentity => { @@ -1952,6 +2005,12 @@ impl<'de> serde::Deserialize<'de> for Undelegate { } delegation_amount__ = map_.next_value()?; } + GeneratedField::FromEpoch => { + if from_epoch__.is_some() { + return Err(serde::de::Error::duplicate_field("fromEpoch")); + } + from_epoch__ = map_.next_value()?; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -1962,6 +2021,7 @@ impl<'de> serde::Deserialize<'de> for Undelegate { start_epoch_index: start_epoch_index__.unwrap_or_default(), unbonded_amount: unbonded_amount__, delegation_amount: delegation_amount__, + from_epoch: from_epoch__, }) } } @@ -2103,6 +2163,9 @@ impl serde::Serialize for UndelegateClaimBody { if self.balance_commitment.is_some() { len += 1; } + if self.unbonding_start_height != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.stake.v1.UndelegateClaimBody", len)?; if let Some(v) = self.validator_identity.as_ref() { struct_ser.serialize_field("validatorIdentity", v)?; @@ -2117,6 +2180,10 @@ impl serde::Serialize for UndelegateClaimBody { if let Some(v) = self.balance_commitment.as_ref() { struct_ser.serialize_field("balanceCommitment", v)?; } + if self.unbonding_start_height != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("unbondingStartHeight", ToString::to_string(&self.unbonding_start_height).as_str())?; + } struct_ser.end() } } @@ -2134,6 +2201,8 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { "penalty", "balance_commitment", "balanceCommitment", + "unbonding_start_height", + "unbondingStartHeight", ]; #[allow(clippy::enum_variant_names)] @@ -2142,6 +2211,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { StartEpochIndex, Penalty, BalanceCommitment, + UnbondingStartHeight, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -2168,6 +2238,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { "startEpochIndex" | "start_epoch_index" => Ok(GeneratedField::StartEpochIndex), "penalty" => Ok(GeneratedField::Penalty), "balanceCommitment" | "balance_commitment" => Ok(GeneratedField::BalanceCommitment), + "unbondingStartHeight" | "unbonding_start_height" => Ok(GeneratedField::UnbondingStartHeight), _ => Ok(GeneratedField::__SkipField__), } } @@ -2191,6 +2262,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { let mut start_epoch_index__ = None; let mut penalty__ = None; let mut balance_commitment__ = None; + let mut unbonding_start_height__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ValidatorIdentity => { @@ -2219,6 +2291,14 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { } balance_commitment__ = map_.next_value()?; } + GeneratedField::UnbondingStartHeight => { + if unbonding_start_height__.is_some() { + return Err(serde::de::Error::duplicate_field("unbondingStartHeight")); + } + unbonding_start_height__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -2229,6 +2309,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimBody { start_epoch_index: start_epoch_index__.unwrap_or_default(), penalty: penalty__, balance_commitment: balance_commitment__, + unbonding_start_height: unbonding_start_height__.unwrap_or_default(), }) } } @@ -2264,6 +2345,9 @@ impl serde::Serialize for UndelegateClaimPlan { if !self.proof_blinding_s.is_empty() { len += 1; } + if self.unbonding_start_height != 0 { + len += 1; + } let mut struct_ser = serializer.serialize_struct("penumbra.core.component.stake.v1.UndelegateClaimPlan", len)?; if let Some(v) = self.validator_identity.as_ref() { struct_ser.serialize_field("validatorIdentity", v)?; @@ -2290,6 +2374,10 @@ impl serde::Serialize for UndelegateClaimPlan { #[allow(clippy::needless_borrow)] struct_ser.serialize_field("proofBlindingS", pbjson::private::base64::encode(&self.proof_blinding_s).as_str())?; } + if self.unbonding_start_height != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("unbondingStartHeight", ToString::to_string(&self.unbonding_start_height).as_str())?; + } struct_ser.end() } } @@ -2313,6 +2401,8 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { "proofBlindingR", "proof_blinding_s", "proofBlindingS", + "unbonding_start_height", + "unbondingStartHeight", ]; #[allow(clippy::enum_variant_names)] @@ -2324,6 +2414,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { BalanceBlinding, ProofBlindingR, ProofBlindingS, + UnbondingStartHeight, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -2353,6 +2444,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { "balanceBlinding" | "balance_blinding" => Ok(GeneratedField::BalanceBlinding), "proofBlindingR" | "proof_blinding_r" => Ok(GeneratedField::ProofBlindingR), "proofBlindingS" | "proof_blinding_s" => Ok(GeneratedField::ProofBlindingS), + "unbondingStartHeight" | "unbonding_start_height" => Ok(GeneratedField::UnbondingStartHeight), _ => Ok(GeneratedField::__SkipField__), } } @@ -2379,6 +2471,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { let mut balance_blinding__ = None; let mut proof_blinding_r__ = None; let mut proof_blinding_s__ = None; + let mut unbonding_start_height__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::ValidatorIdentity => { @@ -2431,6 +2524,14 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) ; } + GeneratedField::UnbondingStartHeight => { + if unbonding_start_height__.is_some() { + return Err(serde::de::Error::duplicate_field("unbondingStartHeight")); + } + unbonding_start_height__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -2444,6 +2545,7 @@ impl<'de> serde::Deserialize<'de> for UndelegateClaimPlan { balance_blinding: balance_blinding__.unwrap_or_default(), proof_blinding_r: proof_blinding_r__.unwrap_or_default(), proof_blinding_s: proof_blinding_s__.unwrap_or_default(), + unbonding_start_height: unbonding_start_height__.unwrap_or_default(), }) } } diff --git a/crates/proto/src/gen/penumbra.view.v1.rs b/crates/proto/src/gen/penumbra.view.v1.rs index 4696a2c477..8763c4218e 100644 --- a/crates/proto/src/gen/penumbra.view.v1.rs +++ b/crates/proto/src/gen/penumbra.view.v1.rs @@ -208,6 +208,13 @@ pub struct TransactionPlannerRequest { pub position_withdraws: ::prost::alloc::vec::Vec< transaction_planner_request::PositionWithdraw, >, + /// The epoch index of the transaction being planned. + #[deprecated] + #[prost(uint64, tag = "200")] + pub epoch_index: u64, + /// The epoch of the transaction being planned. + #[prost(message, optional, tag = "201")] + pub epoch: ::core::option::Option, /// Specifies either that the planner should compute fees automatically or that it should use a fixed fee amount. #[prost(oneof = "transaction_planner_request::FeeMode", tags = "100, 101")] pub fee_mode: ::core::option::Option, diff --git a/crates/proto/src/gen/penumbra.view.v1.serde.rs b/crates/proto/src/gen/penumbra.view.v1.serde.rs index 582a84f478..8380503916 100644 --- a/crates/proto/src/gen/penumbra.view.v1.serde.rs +++ b/crates/proto/src/gen/penumbra.view.v1.serde.rs @@ -5762,6 +5762,12 @@ impl serde::Serialize for TransactionPlannerRequest { if !self.position_withdraws.is_empty() { len += 1; } + if self.epoch_index != 0 { + len += 1; + } + if self.epoch.is_some() { + len += 1; + } if self.fee_mode.is_some() { len += 1; } @@ -5809,6 +5815,13 @@ impl serde::Serialize for TransactionPlannerRequest { if !self.position_withdraws.is_empty() { struct_ser.serialize_field("positionWithdraws", &self.position_withdraws)?; } + if self.epoch_index != 0 { + #[allow(clippy::needless_borrow)] + struct_ser.serialize_field("epochIndex", ToString::to_string(&self.epoch_index).as_str())?; + } + if let Some(v) = self.epoch.as_ref() { + struct_ser.serialize_field("epoch", v)?; + } if let Some(v) = self.fee_mode.as_ref() { match v { transaction_planner_request::FeeMode::AutoFee(v) => { @@ -5851,6 +5864,9 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { "positionCloses", "position_withdraws", "positionWithdraws", + "epoch_index", + "epochIndex", + "epoch", "auto_fee", "autoFee", "manual_fee", @@ -5873,6 +5889,8 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { PositionOpens, PositionCloses, PositionWithdraws, + EpochIndex, + Epoch, AutoFee, ManualFee, __SkipField__, @@ -5911,6 +5929,8 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { "positionOpens" | "position_opens" => Ok(GeneratedField::PositionOpens), "positionCloses" | "position_closes" => Ok(GeneratedField::PositionCloses), "positionWithdraws" | "position_withdraws" => Ok(GeneratedField::PositionWithdraws), + "epochIndex" | "epoch_index" => Ok(GeneratedField::EpochIndex), + "epoch" => Ok(GeneratedField::Epoch), "autoFee" | "auto_fee" => Ok(GeneratedField::AutoFee), "manualFee" | "manual_fee" => Ok(GeneratedField::ManualFee), _ => Ok(GeneratedField::__SkipField__), @@ -5946,6 +5966,8 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { let mut position_opens__ = None; let mut position_closes__ = None; let mut position_withdraws__ = None; + let mut epoch_index__ = None; + let mut epoch__ = None; let mut fee_mode__ = None; while let Some(k) = map_.next_key()? { match k { @@ -6035,6 +6057,20 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { } position_withdraws__ = Some(map_.next_value()?); } + GeneratedField::EpochIndex => { + if epoch_index__.is_some() { + return Err(serde::de::Error::duplicate_field("epochIndex")); + } + epoch_index__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Epoch => { + if epoch__.is_some() { + return Err(serde::de::Error::duplicate_field("epoch")); + } + epoch__ = map_.next_value()?; + } GeneratedField::AutoFee => { if fee_mode__.is_some() { return Err(serde::de::Error::duplicate_field("autoFee")); @@ -6069,6 +6105,8 @@ impl<'de> serde::Deserialize<'de> for TransactionPlannerRequest { position_opens: position_opens__.unwrap_or_default(), position_closes: position_closes__.unwrap_or_default(), position_withdraws: position_withdraws__.unwrap_or_default(), + epoch_index: epoch_index__.unwrap_or_default(), + epoch: epoch__, fee_mode: fee_mode__, }) } diff --git a/crates/proto/src/gen/proto_descriptor.bin.no_lfs b/crates/proto/src/gen/proto_descriptor.bin.no_lfs index bfe27090352a97cdfe387d70fd28a28dea369168..5620d46e543c64f359c7a1b665ee6c7ba82558af 100644 GIT binary patch delta 36206 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 delta 38181 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 Planner { /// /// If you don't specify spends or outputs as well, they will be filled in automatically. #[instrument(skip(self))] - pub fn delegate(&mut self, unbonded_amount: Amount, rate_data: RateData) -> &mut Self { - let delegation = rate_data.build_delegate(unbonded_amount).into(); + pub fn delegate( + &mut self, + epoch: Epoch, + unbonded_amount: Amount, + rate_data: RateData, + ) -> &mut Self { + let delegation = rate_data.build_delegate(epoch, unbonded_amount).into(); self.action(delegation); self } @@ -315,8 +321,13 @@ impl Planner { /// /// TODO: can we put the chain parameters into the planner at the start, so we can compute end_epoch_index? #[instrument(skip(self))] - pub fn undelegate(&mut self, delegation_amount: Amount, rate_data: RateData) -> &mut Self { - let undelegation = rate_data.build_undelegate(delegation_amount).into(); + pub fn undelegate( + &mut self, + epoch: Epoch, + delegation_amount: Amount, + rate_data: RateData, + ) -> &mut Self { + let undelegation = rate_data.build_undelegate(epoch, delegation_amount).into(); self.action(undelegation); self } diff --git a/crates/view/src/service.rs b/crates/view/src/service.rs index 262e033dae..4e6ec60b49 100644 --- a/crates/view/src/service.rs +++ b/crates/view/src/service.rs @@ -515,6 +515,25 @@ impl ViewService for ViewServer { }); } + let current_epoch = if prq.undelegations.is_empty() && prq.delegations.is_empty() { + None + } else { + Some( + prq.epoch + .ok_or_else(|| { + tonic::Status::invalid_argument( + "Missing current epoch in TransactionPlannerRequest", + ) + })? + .try_into() + .map_err(|e| { + tonic::Status::invalid_argument(format!( + "Could not parse current epoch: {e:#}" + )) + })?, + ) + }; + for delegation in prq.delegations { let amount: Amount = delegation .amount @@ -532,7 +551,11 @@ impl ViewService for ViewServer { tonic::Status::invalid_argument(format!("Could not parse rate data: {e:#}")) })?; - planner.delegate(amount, rate_data); + planner.delegate( + current_epoch.expect("checked that current epoch is present"), + amount, + rate_data, + ); } for undelegation in prq.undelegations { @@ -552,7 +575,11 @@ impl ViewService for ViewServer { tonic::Status::invalid_argument(format!("Could not parse rate data: {e:#}")) })?; - planner.undelegate(value.amount, rate_data); + planner.undelegate( + current_epoch.expect("checked that current epoch is present"), + value.amount, + rate_data, + ); } for position_open in prq.position_opens { diff --git a/crates/wallet/Cargo.toml b/crates/wallet/Cargo.toml index 34c9768915..30a645a3aa 100644 --- a/crates/wallet/Cargo.toml +++ b/crates/wallet/Cargo.toml @@ -31,6 +31,7 @@ penumbra-num = {workspace = true, default-features = true} penumbra-proto = {workspace = true, default-features = true} penumbra-stake = {workspace = true, default-features = true} penumbra-tct = {workspace = true, default-features = true} +penumbra-sct = {workspace = true, default-features = false} penumbra-transaction = {workspace = true, default-features = true} penumbra-view = {workspace = true} pin-project = {workspace = true} diff --git a/crates/wallet/src/plan.rs b/crates/wallet/src/plan.rs index b41041003a..dd993ccb5b 100644 --- a/crates/wallet/src/plan.rs +++ b/crates/wallet/src/plan.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use anyhow::{Context, Result}; use ark_std::UniformRand; use decaf377::Fq; +use penumbra_sct::epoch::Epoch; use rand_core::{CryptoRng, RngCore}; use tracing::instrument; @@ -62,6 +63,7 @@ where pub async fn delegate( view: &mut V, rng: R, + epoch: Epoch, rate_data: RateData, unbonded_amount: Amount, fee: Fee, @@ -73,7 +75,7 @@ where { Planner::new(rng) .fee(fee) - .delegate(unbonded_amount, rate_data) + .delegate(epoch, unbonded_amount, rate_data) .plan(view, source_address) .await .context("can't build delegate plan") diff --git a/proto/penumbra/penumbra/core/component/stake/v1/stake.proto b/proto/penumbra/penumbra/core/component/stake/v1/stake.proto index dda7ee26b2..6fa3231447 100644 --- a/proto/penumbra/penumbra/core/component/stake/v1/stake.proto +++ b/proto/penumbra/penumbra/core/component/stake/v1/stake.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package penumbra.core.component.stake.v1; import "penumbra/core/asset/v1/asset.proto"; +import "penumbra/core/component/sct/v1/sct.proto"; import "penumbra/core/keys/v1/keys.proto"; import "penumbra/core/num/v1/num.proto"; @@ -67,7 +68,7 @@ message FundingStream { // Describes the reward and exchange rates and voting power for a validator in some epoch. message RateData { keys.v1.IdentityKey identity_key = 1; - uint64 epoch_index = 2; + uint64 epoch_index = 2 [deprecated = true]; num.v1.Amount validator_reward_rate = 4; num.v1.Amount validator_exchange_rate = 5; } @@ -96,7 +97,8 @@ message BondingState { BONDING_STATE_ENUM_UNBONDED = 3; } BondingStateEnum state = 1; - uint64 unbonds_at_epoch = 2; + uint64 unbonds_at_epoch = 2 [deprecated = true]; + uint64 unbonds_at_height = 3; } // Describes the state of a validator @@ -151,7 +153,7 @@ message Undelegate { // The identity key of the validator to undelegate from. keys.v1.IdentityKey validator_identity = 1; // The index of the epoch in which this undelegation was performed. - uint64 start_epoch_index = 2; + uint64 start_epoch_index = 2 [deprecated = true]; // The amount to undelegate, in units of unbonding tokens. num.v1.Amount unbonded_amount = 3; // The amount of delegation tokens consumed by this action. @@ -160,6 +162,8 @@ message Undelegate { // (and should be checked in transaction validation!), but including it allows // stateless verification that the transaction is internally consistent. num.v1.Amount delegation_amount = 4; + // The epoch in which this delegation was performed. + penumbra.core.component.sct.v1.Epoch from_epoch = 5; } // A transaction action finishing an undelegation, converting (slashable) @@ -173,19 +177,21 @@ message UndelegateClaimBody { // The identity key of the validator to finish undelegating from. keys.v1.IdentityKey validator_identity = 1; // The epoch in which unbonding began, used to verify the penalty. - uint64 start_epoch_index = 2; + uint64 start_epoch_index = 2 [deprecated = true]; // The penalty applied to undelegation, in bps^2 (10e-8). // In the happy path (no slashing), this is 0. Penalty penalty = 3; // The action's contribution to the transaction's value balance. asset.v1.BalanceCommitment balance_commitment = 4; + /// The starting height of the epoch during which unbonding began. + uint64 unbonding_start_height = 5; } message UndelegateClaimPlan { // The identity key of the validator to finish undelegating from. keys.v1.IdentityKey validator_identity = 1; // The epoch in which unbonding began, used to verify the penalty. - uint64 start_epoch_index = 2; + uint64 start_epoch_index = 2 [deprecated = true]; // The penalty applied to undelegation, in bps^2 (10e-8). // In the happy path (no slashing), this is 0. Penalty penalty = 4; @@ -198,6 +204,8 @@ message UndelegateClaimPlan { bytes proof_blinding_r = 7; // The second blinding factor to use for the ZK undelegate claim proof. bytes proof_blinding_s = 8; + // The height during which unbonding began. + uint64 unbonding_start_height = 9; } // A list of pending delegations and undelegations. @@ -273,7 +281,7 @@ message CurrentValidatorRateResponse { // Staking configuration data. message StakeParameters { // The number of epochs an unbonding note for before being released. - uint64 unbonding_epochs = 1; + uint64 unbonding_epochs = 1 [deprecated = true]; // The maximum number of validators in the consensus set. uint64 active_validator_limit = 2; // The base reward rate, expressed in basis points of basis points @@ -288,6 +296,8 @@ message StakeParameters { uint64 missed_blocks_maximum = 7; // The minimum amount of stake required for a validator to be indexed by the protocol. num.v1.Amount min_validator_stake = 8; + // The number of blocks that must elapse before an unbonding note can be claimed. + uint64 unbonding_delay = 9; } // Genesis data for the staking component. diff --git a/proto/penumbra/penumbra/view/v1/view.proto b/proto/penumbra/penumbra/view/v1/view.proto index 8cdd1d62ba..ab46b161b6 100644 --- a/proto/penumbra/penumbra/view/v1/view.proto +++ b/proto/penumbra/penumbra/view/v1/view.proto @@ -217,7 +217,13 @@ message TransactionPlannerRequest { core.component.fee.v1.FeeTier auto_fee = 100; // A manually set fee, rather than automatically computing a fee based on gas use. core.component.fee.v1.Fee manual_fee = 101; - }; + } + + // The epoch index of the transaction being planned. + uint64 epoch_index = 200 [deprecated = true]; + + // The epoch of the transaction being planned. + penumbra.core.component.sct.v1.Epoch epoch = 201; // Request message subtypes message Output {