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#[derive(Debug, clap::Subcommand)]
37pub enum ValidatorCmd {
38 List {
40 #[clap(short = 'i', long)]
42 show_inactive: bool,
43 #[clap(short, long)]
45 detailed: bool,
46 },
47 Definition {
49 #[clap(long)]
51 file: Option<String>,
52 identity_key: String,
54 },
55 Uptime {
57 identity_key: String,
59 },
60 Status {
62 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 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; 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 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 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 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 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 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 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 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 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 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 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 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 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 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
400struct 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 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 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}