pcli/command/query/
validator.rs

1use std::{
2    fs::File,
3    io::Write,
4    ops::{Deref, RangeInclusive},
5    time::Duration,
6};
7
8use anyhow::{anyhow, Context, Error, Result};
9use colored::Colorize;
10use comfy_table::{presets, Table};
11use futures::TryStreamExt;
12use penumbra_sdk_app::params::AppParameters;
13use penumbra_sdk_num::{fixpoint::U128x128, Amount};
14use penumbra_sdk_proto::{
15    core::{
16        app::v1::{
17            query_service_client::QueryServiceClient as AppQueryServiceClient, AppParametersRequest,
18        },
19        component::stake::v1::{
20            query_service_client::QueryServiceClient as StakeQueryServiceClient,
21            GetValidatorInfoRequest, GetValidatorInfoResponse, ValidatorInfoRequest,
22            ValidatorStatusRequest, ValidatorUptimeRequest,
23        },
24    },
25    DomainType,
26};
27use penumbra_sdk_stake::{
28    rate::RateData,
29    validator::{self, Info, Status, Validator, ValidatorToml},
30    IdentityKey, Uptime, BPS_SQUARED_SCALING_FACTOR,
31};
32
33use crate::App;
34
35// TODO: replace this with something more standard for the `query` subcommand
36#[derive(Debug, clap::Subcommand)]
37pub enum ValidatorCmd {
38    /// List all the validators in the network.
39    List {
40        /// Whether to show validators that are not currently part of the consensus set.
41        #[clap(short = 'i', long)]
42        show_inactive: bool,
43        /// Whether to show detailed validator info.
44        #[clap(short, long)]
45        detailed: bool,
46    },
47    /// Fetch the current definition for a particular validator.
48    Definition {
49        /// The JSON file to write the definition to [default: stdout].
50        #[clap(long)]
51        file: Option<String>,
52        /// The identity key of the validator to fetch.
53        identity_key: String,
54    },
55    /// Get the uptime of the validator.
56    Uptime {
57        /// The identity key of the validator to fetch.
58        identity_key: String,
59    },
60    /// Fetch the current status for a particular validator.
61    Status {
62        /// The identity key of the validator to fetch.
63        identity_key: String,
64    },
65}
66
67impl ValidatorCmd {
68    pub async fn exec(&self, app: &mut App) -> Result<()> {
69        match self {
70            ValidatorCmd::List {
71                show_inactive,
72                detailed,
73            } => {
74                let mut client = StakeQueryServiceClient::new(app.pd_channel().await?);
75
76                let mut validators = client
77                    .validator_info(ValidatorInfoRequest {
78                        show_inactive: *show_inactive,
79                        ..Default::default()
80                    })
81                    .await?
82                    .into_inner()
83                    .try_collect::<Vec<_>>()
84                    .await?
85                    .into_iter()
86                    .map(TryInto::try_into)
87                    .collect::<Result<Vec<validator::Info>, _>>()?;
88
89                // Sort by voting power (descending), active first, then inactive
90                validators.sort_by(|a, b| {
91                    let av = if matches!(a.status.state, validator::State::Active) {
92                        (a.status.voting_power, Amount::zero())
93                    } else {
94                        (Amount::zero(), a.status.voting_power)
95                    };
96                    let bv = if matches!(b.status.state, validator::State::Active) {
97                        (b.status.voting_power, Amount::zero())
98                    } else {
99                        (Amount::zero(), b.status.voting_power)
100                    };
101
102                    bv.cmp(&av)
103                });
104
105                let total_voting_power = validators
106                    .iter()
107                    .filter_map(|v| {
108                        if let validator::State::Active = v.status.state {
109                            Some(v.status.voting_power.value())
110                        } else {
111                            None
112                        }
113                    })
114                    .sum::<u128>() as f64;
115
116                let mut table = Table::new();
117                table.load_preset(presets::NOTHING);
118                table.set_header(vec![
119                    "Voting Power",
120                    "Share",
121                    "Commission",
122                    "State",
123                    "Bonding State",
124                    "Validator Info",
125                ]);
126
127                for v in validators {
128                    let voting_power = (v.status.voting_power.value() as f64) * 1e-6; // apply udelegation factor
129                    let active_voting_power = if matches!(v.status.state, validator::State::Active)
130                    {
131                        v.status.voting_power.value() as f64
132                    } else {
133                        0.0
134                    };
135                    let power_percent = 100.0 * active_voting_power / total_voting_power;
136                    let commission_bps = v
137                        .validator
138                        .funding_streams
139                        .as_ref()
140                        .iter()
141                        .map(|fs| fs.rate_bps())
142                        .sum::<u16>();
143
144                    table.add_row(vec![
145                        format!("{voting_power:.3}"),
146                        format!("{power_percent:.2}%"),
147                        format!("{commission_bps}bps"),
148                        v.status.state.to_string(),
149                        v.status.bonding_state.to_string(),
150                        // TODO: consider rewriting this with term colors
151                        // at some point, when we get around to it
152                        v.validator.identity_key.to_string().red().to_string(),
153                    ]);
154                    table.add_row(vec![
155                        "".into(),
156                        "".into(),
157                        "".into(),
158                        "".into(),
159                        "".into(),
160                        v.validator.name.to_string().bright_green().to_string(),
161                    ]);
162                    if *detailed {
163                        table.add_row(vec![
164                            "".into(),
165                            "".into(),
166                            "".into(),
167                            "".into(),
168                            "".into(),
169                            format!("  {}", v.validator.description),
170                        ]);
171                        table.add_row(vec![
172                            "".into(),
173                            "".into(),
174                            "".into(),
175                            "".into(),
176                            "".into(),
177                            format!("  {}", v.validator.website),
178                        ]);
179                    }
180                }
181
182                println!("{table}");
183            }
184            ValidatorCmd::Definition { file, identity_key } => {
185                // Parse the identity key and construct the RPC request.
186                let request = tonic::Request::new(GetValidatorInfoRequest {
187                    identity_key: identity_key
188                        .parse::<IdentityKey>()
189                        .map(|ik| ik.to_proto())
190                        .map(Some)?,
191                });
192
193                // Instantiate an RPC client and send the request.
194                let GetValidatorInfoResponse { validator_info } = app
195                    .pd_channel()
196                    .await
197                    .map(StakeQueryServiceClient::new)?
198                    .get_validator_info(request)
199                    .await?
200                    .into_inner();
201
202                // Coerce the validator information into TOML, or return an error if it was not
203                // found within the client's response.
204                let serialize = |v| toml::to_string_pretty(&v).map_err(Error::from);
205                let toml = validator_info
206                    .ok_or_else(|| anyhow!("response did not include validator info"))?
207                    .try_into()
208                    .context("parsing validator info")
209                    .map(|Info { validator, .. }| validator)
210                    .map(ValidatorToml::from)
211                    .and_then(serialize)?;
212
213                // Write to a file if an output file was specified, otherwise print to stdout.
214                if let Some(file) = file {
215                    File::create(file)
216                        .with_context(|| format!("cannot create file {file:?}"))?
217                        .write_all(toml.as_bytes())
218                        .context("could not write file")?;
219                } else {
220                    println!("{}", toml);
221                }
222            }
223            ValidatorCmd::Uptime { identity_key } => {
224                let identity_key = identity_key.parse::<IdentityKey>()?;
225
226                let mut client = StakeQueryServiceClient::new(app.pd_channel().await?);
227
228                // What's the uptime?
229                let uptime: Uptime = client
230                    .validator_uptime(ValidatorUptimeRequest {
231                        identity_key: Some(identity_key.into()),
232                    })
233                    .await?
234                    .into_inner()
235                    .uptime
236                    .ok_or_else(|| anyhow::anyhow!("uptime must be present in response"))?
237                    .try_into()?;
238
239                // Is the validator active?
240                let status: validator::Status = client
241                    .validator_status(ValidatorStatusRequest {
242                        identity_key: Some(identity_key.into()),
243                    })
244                    .await?
245                    .into_inner()
246                    .status
247                    .ok_or_else(|| anyhow::anyhow!("status must be present in response"))?
248                    .try_into()?;
249                let state = status.state;
250                let active = matches!(state, validator::State::Active);
251
252                // Get the chain parameters
253                let mut client = AppQueryServiceClient::new(app.pd_channel().await?);
254                let params: AppParameters = client
255                    .app_parameters(tonic::Request::new(AppParametersRequest {}))
256                    .await?
257                    .into_inner()
258                    .app_parameters
259                    .ok_or_else(|| anyhow::anyhow!("empty AppParametersResponse message"))?
260                    .try_into()?;
261
262                let as_of_height = uptime.as_of_height();
263                let missed_blocks = uptime.num_missed_blocks();
264                let window_len = uptime.missed_blocks_window();
265
266                let mut downtime_ranges: Vec<RangeInclusive<u64>> = vec![];
267                for missed_block in uptime.missed_blocks() {
268                    if let Some(range) = downtime_ranges.last_mut() {
269                        if range.end() + 1 == missed_block {
270                            *range = *range.start()..=missed_block;
271                        } else {
272                            downtime_ranges.push(missed_block..=missed_block);
273                        }
274                    } else {
275                        downtime_ranges.push(missed_block..=missed_block);
276                    }
277                }
278
279                let percent_uptime =
280                    100.0 * (window_len as f64 - missed_blocks as f64) / window_len as f64;
281                let signed_blocks = window_len as u64 - missed_blocks as u64;
282                let min_uptime_blocks =
283                    window_len as u64 - params.stake_params.missed_blocks_maximum;
284                let percent_min_uptime = 100.0 * min_uptime_blocks as f64 / window_len as f64;
285                let percent_max_downtime =
286                    100.0 * params.stake_params.missed_blocks_maximum as f64 / window_len as f64;
287                let percent_downtime = 100.0 * missed_blocks as f64 / window_len as f64;
288                let percent_downtime_penalty =
289                    // Converting from basis points squared to percentage: basis point ^ 2  %-age
290                    //                                                    /--------------\/-----\
291                    params.stake_params.slashing_penalty_downtime as f64 / 100.0 / 100.0 / 100.0;
292                let min_remaining_downtime_blocks = (window_len as u64)
293                    .saturating_sub(missed_blocks as u64)
294                    .saturating_sub(min_uptime_blocks);
295                let min_remaining_downtime = humantime::Duration::from(Duration::from_secs(
296                    (min_remaining_downtime_blocks * 5) as u64,
297                ));
298                let cumulative_downtime =
299                    humantime::Duration::from(Duration::from_secs((missed_blocks * 5) as u64));
300                let percent_grace = 100.0 * min_remaining_downtime_blocks as f64
301                    / (window_len - min_uptime_blocks as usize) as f64;
302                let window_len_len = window_len.to_string().len();
303
304                println!("{state} validator: as of block {as_of_height}");
305                println!("Achieved signing: {percent_uptime:>6.2}% = {signed_blocks:width$}/{window_len} most-recent blocks", width = window_len_len);
306                if active {
307                    println!("Required signing: {percent_min_uptime:>6.2}% = {min_uptime_blocks:width$}/{window_len} most-recent blocks", width = window_len_len);
308                }
309                println!("Salient downtime: {percent_downtime:>6.2}% = {missed_blocks:width$}/{window_len} most-recent blocks ~ {cumulative_downtime} cumulative downtime", width = window_len_len);
310                if active {
311                    println!("Unexpended grace: {percent_grace:>6.2}% = {min_remaining_downtime_blocks:width$}/{window_len} forthcoming blocks ~ {min_remaining_downtime} at minimum before penalty", width = window_len_len);
312                    println!( "Downtime penalty: {percent_downtime_penalty:>6.2}% - if downtime exceeds {percent_max_downtime:.2}%, penalty will be applied to all delegations");
313                }
314                if !downtime_ranges.is_empty() {
315                    println!("Downtime details:");
316                    let mut max_blocks_width = 0;
317                    let mut max_start_width = 0;
318                    let mut max_end_width = 0;
319                    for range in downtime_ranges.iter() {
320                        let blocks = range.end() - range.start() + 1;
321                        max_blocks_width = max_blocks_width.max(blocks.to_string().len());
322                        max_start_width = max_start_width.max(range.start().to_string().len());
323                        if blocks != 1 {
324                            max_end_width = max_end_width.max(range.end().to_string().len());
325                        }
326                    }
327                    for range in downtime_ranges.iter() {
328                        let blocks = range.end() - range.start() + 1;
329                        let estimated_duration =
330                            humantime::Duration::from(Duration::from_secs((blocks * 5) as u64));
331                        if blocks == 1 {
332                            let height = range.start();
333                            println!(
334                                "  • {blocks:width$} missed:  block {height:>height_width$} {empty:>duration_width$}(~ {estimated_duration})",
335                                width = max_blocks_width,
336                                height_width = max_start_width,
337                                duration_width = max_end_width + 5,
338                                empty = "",
339                            );
340                        } else {
341                            let start = range.start();
342                            let end = range.end();
343                            println!(
344                                "  • {blocks:width$} missed: blocks {start:>start_width$} ..= {end:>end_width$} (~ {estimated_duration})",
345                                width = max_blocks_width,
346                                start_width = max_start_width,
347                                end_width = max_end_width,
348                            );
349                        };
350                    }
351                }
352            }
353            ValidatorCmd::Status { identity_key } => {
354                // Parse the identity key and construct the RPC request.
355                let request = tonic::Request::new(GetValidatorInfoRequest {
356                    identity_key: identity_key
357                        .parse::<IdentityKey>()
358                        .map(|ik| ik.to_proto())
359                        .map(Some)?,
360                });
361
362                // Instantiate an RPC client and send the request.
363                let GetValidatorInfoResponse { validator_info } = app
364                    .pd_channel()
365                    .await
366                    .map(StakeQueryServiceClient::new)?
367                    .get_validator_info(request)
368                    .await?
369                    .into_inner();
370
371                // Parse the validator status, or return an error if it was not found within the
372                // client's response.
373                let info = validator_info
374                    .ok_or_else(|| anyhow!("response did not include validator info"))?
375                    .try_into()
376                    .context("parsing validator info")?;
377
378                // Initialize a table, add a header and insert this validator's information.
379                let mut table = Table::new();
380                table
381                    .load_preset(presets::NOTHING)
382                    .set_header(vec![
383                        "Voting Power",
384                        "Commission",
385                        "State",
386                        "Bonding State",
387                        "Exchange Rate",
388                        "Identity Key",
389                        "Name",
390                    ])
391                    .add_row(StatusRow::new(info));
392                println!("{table}");
393            }
394        }
395
396        Ok(())
397    }
398}
399
400/// A row within the `status` command's table output.
401struct StatusRow {
402    power: f64,
403    commission: u16,
404    state: validator::State,
405    bonding_state: validator::BondingState,
406    exchange_rate: U128x128,
407    identity_key: IdentityKey,
408    name: String,
409}
410
411impl StatusRow {
412    /// Constructs a new [`StatusRow`].
413    fn new(
414        Info {
415            validator:
416                Validator {
417                    funding_streams,
418                    identity_key,
419                    name,
420                    ..
421                },
422            status:
423                Status {
424                    state,
425                    bonding_state,
426                    voting_power,
427                    ..
428                },
429            rate_data:
430                RateData {
431                    validator_exchange_rate,
432                    ..
433                },
434        }: Info,
435    ) -> Self {
436        // Calculate the scaled voting power, exchange rate, and commissions.
437        let power = (voting_power.value() as f64) * 1e-6;
438        let commission = funding_streams.iter().map(|fs| fs.rate_bps()).sum();
439        let exchange_rate = {
440            let rate_bps_sq = U128x128::from(validator_exchange_rate);
441            (rate_bps_sq / BPS_SQUARED_SCALING_FACTOR.deref()).expect("nonzero scaling factor")
442        };
443
444        Self {
445            power,
446            commission,
447            state,
448            bonding_state,
449            exchange_rate,
450            identity_key,
451            name,
452        }
453    }
454}
455
456impl Into<comfy_table::Row> for StatusRow {
457    fn into(self) -> comfy_table::Row {
458        let Self {
459            power,
460            commission,
461            state,
462            bonding_state,
463            exchange_rate,
464            identity_key,
465            name,
466        } = self;
467
468        [
469            format!("{power:.3}"),
470            format!("{commission}bps"),
471            state.to_string(),
472            bonding_state.to_string(),
473            exchange_rate.to_string(),
474            identity_key.to_string(),
475            name,
476        ]
477        .into()
478    }
479}