1use std::{
2 fs::File,
3 io::{Read, Write},
4};
5
6use anyhow::{Context, Result};
7use base64::{engine::general_purpose::URL_SAFE, Engine as _};
8use decaf377_rdsa::{Signature, SpendAuth};
9use penumbra_sdk_view::Planner;
10use rand_core::OsRng;
11use serde_json::Value;
12
13use penumbra_sdk_governance::{
14 ValidatorVote, ValidatorVoteBody, ValidatorVoteReason, Vote, MAX_VALIDATOR_VOTE_REASON_LENGTH,
15};
16use penumbra_sdk_proto::{view::v1::GasPricesRequest, DomainType};
17use penumbra_sdk_stake::{
18 validator,
19 validator::{Validator, ValidatorToml},
20 FundingStream, FundingStreams, IdentityKey,
21};
22
23use crate::App;
24
25use penumbra_sdk_fee::FeeTier;
26
27#[derive(Debug, clap::Subcommand)]
28pub enum ValidatorCmd {
29 Identity {
31 #[clap(long)]
33 base64: bool,
34 },
35 GovernanceKey {
37 #[clap(long)]
39 base64: bool,
40 },
41 #[clap(subcommand)]
43 Definition(DefinitionCmd),
44 #[clap(subcommand)]
46 Vote(VoteCmd),
47}
48
49#[derive(Debug, clap::Subcommand)]
50pub enum VoteCmd {
51 Cast {
54 #[clap(long, default_value = "0", global = true, display_order = 300)]
56 source: u32,
57 #[clap(subcommand)]
59 vote: super::tx::VoteCmd,
60 #[clap(long, default_value = "", global = true, display_order = 400)]
62 reason: String,
63 #[clap(long, global = true, display_order = 500)]
68 signature: Option<String>,
69 #[clap(long, global = true, display_order = 600)]
74 validator: Option<IdentityKey>,
75 #[clap(short, long, default_value_t)]
77 fee_tier: FeeTier,
78 },
79 Sign {
81 #[clap(subcommand)]
83 vote: super::tx::VoteCmd,
84 #[clap(long, default_value = "", global = true, display_order = 400)]
86 reason: String,
87 #[clap(long, global = true, display_order = 500)]
89 signature_file: Option<String>,
90 #[clap(long, global = true, display_order = 600)]
95 validator: Option<IdentityKey>,
96 },
97}
98
99#[derive(Debug, clap::Subcommand)]
100pub enum DefinitionCmd {
101 Upload {
103 #[clap(long)]
105 file: String,
106 #[clap(long, default_value = "0")]
108 source: u32,
109 #[clap(long)]
114 signature: Option<String>,
115 #[clap(short, long, default_value_t)]
117 fee_tier: FeeTier,
118 },
119 Sign {
121 #[clap(long)]
123 file: String,
124 #[clap(long)]
126 signature_file: Option<String>,
127 },
128 Template {
133 #[clap(long)]
135 file: Option<String>,
136
137 #[clap(short = 'k', long)]
142 tendermint_validator_keyfile: Option<camino::Utf8PathBuf>,
143 },
144 Fetch {
146 #[clap(long)]
148 file: Option<String>,
149 },
150}
151
152impl ValidatorCmd {
153 pub fn offline(&self) -> bool {
154 match self {
155 ValidatorCmd::Identity { .. } => true,
156 ValidatorCmd::GovernanceKey { .. } => true,
157 ValidatorCmd::Definition(
158 DefinitionCmd::Template { .. } | DefinitionCmd::Sign { .. },
159 ) => true,
160 ValidatorCmd::Definition(
161 DefinitionCmd::Upload { .. } | DefinitionCmd::Fetch { .. },
162 ) => false,
163 ValidatorCmd::Vote(VoteCmd::Sign { .. }) => true,
164 ValidatorCmd::Vote(VoteCmd::Cast { .. }) => false,
165 }
166 }
167
168 pub async fn exec(&self, app: &mut App) -> Result<()> {
169 let fvk = app.config.full_viewing_key.clone();
170
171 match self {
172 ValidatorCmd::Identity { base64 } => {
173 let ik = IdentityKey(fvk.spend_verification_key().clone().into());
174
175 if *base64 {
176 use base64::{display::Base64Display, engine::general_purpose::STANDARD};
177 println!("{}", Base64Display::new(ik.0.as_ref(), &STANDARD));
178 } else {
179 println!("{ik}");
180 }
181 }
182 ValidatorCmd::GovernanceKey { base64 } => {
183 let gk = app.config.governance_key();
184
185 if *base64 {
186 use base64::{display::Base64Display, engine::general_purpose::STANDARD};
187 println!("{}", Base64Display::new(&gk.0.to_bytes(), &STANDARD));
188 } else {
189 println!("{gk}");
190 }
191 }
192 ValidatorCmd::Definition(DefinitionCmd::Sign {
193 file,
194 signature_file,
195 }) => {
196 let new_validator = read_validator_toml(file)?;
197
198 let input_file_path = std::fs::canonicalize(file)
199 .with_context(|| format!("invalid path: {file:?}"))?;
200 let input_file_name = input_file_path
201 .file_name()
202 .with_context(|| format!("invalid path: {file:?}"))?;
203
204 let signature = app.sign_validator_definition(new_validator.clone()).await?;
205
206 if let Some(output_file) = signature_file {
207 let output_file_path = std::fs::canonicalize(output_file)
208 .with_context(|| format!("invalid path: {output_file:?}"))?;
209 let output_file_name = output_file_path
210 .file_name()
211 .with_context(|| format!("invalid path: {output_file:?}"))?;
212 File::create(output_file)
213 .with_context(|| format!("cannot create file {output_file:?}"))?
214 .write_all(URL_SAFE.encode(signature.encode_to_vec()).as_bytes())
215 .with_context(|| format!("could not write file {output_file:?}"))?;
216 println!(
217 "Signed validator definition #{} for {}\nWrote signature to {output_file_path:?}",
218 new_validator.sequence_number,
219 new_validator.identity_key,
220 );
221 println!(
222 "To upload the definition, use the below command with the exact same definition file:\n\n $ pcli validator definition upload --file {:?} --signature - < {:?}",
223 input_file_name,
224 output_file_name,
225 );
226 } else {
227 println!(
228 "Signed validator definition #{} for {}\nTo upload the definition, use the below command with the exact same definition file:\n\n $ pcli validator definition upload --file {:?} \\\n --signature {}",
229 new_validator.sequence_number,
230 new_validator.identity_key,
231 input_file_name,
232 URL_SAFE.encode(signature.encode_to_vec())
233 );
234 }
235 }
236 ValidatorCmd::Definition(DefinitionCmd::Upload {
237 file,
238 source,
239 signature,
240 fee_tier,
241 }) => {
242 let gas_prices = app
243 .view
244 .as_mut()
245 .context("view service must be initialized")?
246 .gas_prices(GasPricesRequest {})
247 .await?
248 .into_inner()
249 .gas_prices
250 .expect("gas prices must be available")
251 .try_into()?;
252
253 let new_validator = read_validator_toml(file)?;
254
255 let auth_sig = if let Some(signature) = signature {
258 let mut signature = signature.clone();
260 if signature == "-" {
261 let mut buf = String::new();
262 std::io::stdin().read_to_string(&mut buf)?;
263 signature = buf;
264 }
265 <Signature<SpendAuth> as penumbra_sdk_proto::DomainType>::decode(
266 &URL_SAFE
267 .decode(signature)
268 .context("unable to decode signature as base64")?[..],
269 )
270 .context("unable to parse decoded signature")?
271 } else {
272 app.sign_validator_definition(new_validator.clone()).await?
273 };
274 let vd = validator::Definition {
275 validator: new_validator,
276 auth_sig,
277 };
278 let plan = Planner::new(OsRng)
281 .validator_definition(vd)
282 .set_gas_prices(gas_prices)
283 .set_fee_tier((*fee_tier).into())
284 .plan(app.view(), source.into())
285 .await?;
286
287 app.build_and_submit_transaction(plan).await?;
288 println!("Uploaded validator definition");
292 }
293 ValidatorCmd::Vote(VoteCmd::Sign {
294 vote,
295 reason,
296 signature_file,
297 validator,
298 }) => {
299 let identity_key = validator
300 .unwrap_or_else(|| IdentityKey(fvk.spend_verification_key().clone().into()));
301 let governance_key = app.config.governance_key();
302
303 let (proposal, vote): (u64, Vote) = (*vote).into();
304
305 if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
306 anyhow::bail!("validator vote reason is too long, max 1024 bytes");
307 }
308
309 let body = ValidatorVoteBody {
311 proposal,
312 vote,
313 identity_key,
314 governance_key,
315 reason: ValidatorVoteReason(reason.clone()),
316 };
317
318 let signature = app.sign_validator_vote(body).await?;
319
320 if let Some(signature_file) = signature_file {
321 File::create(signature_file)
322 .with_context(|| format!("cannot create file {signature_file:?}"))?
323 .write_all(URL_SAFE.encode(signature.encode_to_vec()).as_bytes())
324 .context("could not write file")?;
325 let output_file_path = std::fs::canonicalize(signature_file)
326 .with_context(|| format!("invalid path: {signature_file:?}"))?;
327 println!(
328 "Signed validator vote {vote} on proposal #{proposal} by {identity_key}\nWrote signature to {output_file_path:?}",
329 );
330 println!(
331 "To cast the vote, use the below command:\n\n $ pcli validator vote cast {vote} --on {proposal} --reason {reason:?} --signature - < {signature_file:?}",
332 );
333 } else {
334 println!(
335 "Signed validator vote {vote} on proposal #{proposal} by {identity_key}\nTo cast the vote, use the below command:\n\n $ pcli validator vote cast {vote} --on {proposal} --reason {reason:?} \\\n --signature {}",
336 URL_SAFE.encode(signature.encode_to_vec())
337 );
338 }
339 }
340 ValidatorCmd::Vote(VoteCmd::Cast {
341 source,
342 vote,
343 reason,
344 signature,
345 validator,
346 fee_tier,
347 }) => {
348 let gas_prices = app
349 .view
350 .as_mut()
351 .context("view service must be initialized")?
352 .gas_prices(GasPricesRequest {})
353 .await?
354 .into_inner()
355 .gas_prices
356 .expect("gas prices must be available")
357 .try_into()?;
358
359 let identity_key = validator
360 .unwrap_or_else(|| IdentityKey(fvk.spend_verification_key().clone().into()));
361 let governance_key = app.config.governance_key();
362
363 let (proposal, vote): (u64, Vote) = (*vote).into();
364
365 if reason.len() > MAX_VALIDATOR_VOTE_REASON_LENGTH {
366 anyhow::bail!("validator vote reason is too long, max 1024 bytes");
367 }
368
369 let body = ValidatorVoteBody {
371 proposal,
372 vote,
373 identity_key,
374 governance_key,
375 reason: ValidatorVoteReason(reason.clone()),
376 };
377
378 let auth_sig = if let Some(signature) = signature {
381 let mut signature = signature.clone();
383 if signature == "-" {
384 let mut buf = String::new();
385 std::io::stdin().read_to_string(&mut buf)?;
386 signature = buf;
387 }
388 <Signature<SpendAuth> as penumbra_sdk_proto::DomainType>::decode(
389 &URL_SAFE
390 .decode(signature)
391 .context("unable to decode signature as base64")?[..],
392 )
393 .context("unable to parse decoded signature")?
394 } else {
395 app.sign_validator_vote(body.clone()).await?
396 };
397
398 let vote = ValidatorVote { body, auth_sig };
399
400 let plan = Planner::new(OsRng)
402 .set_gas_prices(gas_prices)
403 .set_fee_tier((*fee_tier).into())
404 .validator_vote(vote)
405 .plan(app.view(), source.into())
406 .await?;
407
408 app.build_and_submit_transaction(plan).await?;
409
410 println!("Cast validator vote");
411 }
412 ValidatorCmd::Definition(DefinitionCmd::Template {
413 file,
414 tendermint_validator_keyfile,
415 }) => {
416 let (address, _dtk) = fvk.incoming().payment_address(0u32.into());
417 let identity_key = IdentityKey(fvk.spend_verification_key().clone().into());
418 let governance_key = app.config.governance_key();
422
423 let consensus_key: tendermint::PublicKey = match tendermint_validator_keyfile {
426 Some(f) => {
427 tracing::debug!(?f, "Reading tendermint validator pubkey from file");
428 let tm_key_config: Value =
429 serde_json::from_str(&std::fs::read_to_string(f)?).context(format!(
430 "Could not parse file as Tendermint validator config: {f}"
431 ))?;
432 serde_json::value::from_value::<tendermint::PublicKey>(
433 tm_key_config["pub_key"].clone(),
434 )
435 .context(format!("Tendermint JSON file malformed: {f}"))?
436 }
437 None => {
438 tracing::warn!("Generating a random consensus pubkey for Tendermint; consider using the '--tendermint-validator-keyfile' flag");
439 generate_new_tendermint_keypair()?.public_key()
440 }
441 };
442
443 let generated_key_notice: String = match tendermint_validator_keyfile {
445 Some(_s) => String::from(""),
446 None => {
447 "\n# The consensus_key field is random, and needs to be replaced with your
448# tendermint instance's public key, which can be found in `priv_validator_key.json`.
449#"
450 .to_string()
451 }
452 };
453
454 let template: ValidatorToml = Validator {
455 identity_key,
456 governance_key,
457 consensus_key,
458 name: String::new(),
459 website: String::new(),
460 description: String::new(),
461 enabled: false,
464 funding_streams: FundingStreams::try_from(vec![
465 FundingStream::ToAddress {
466 address,
467 rate_bps: 100,
468 },
469 FundingStream::ToCommunityPool { rate_bps: 100 },
470 ])?,
471 sequence_number: 0,
472 }
473 .into();
474
475 let template_str = format!(
476 "# This is a template for a validator definition.
477#
478# The identity_key and governance_key fields are auto-filled with values derived
479# from this wallet's account.
480# {}
481# You should fill in the name, website, and description fields.
482#
483# By default, validators are disabled, and cannot be delegated to. To change
484# this, set `enabled = true`.
485#
486# Every time you upload a new validator config, you'll need to increment the
487# `sequence_number`.
488
489{}
490",
491 generated_key_notice,
492 toml::to_string_pretty(&template)?
493 );
494
495 if let Some(file) = file {
496 File::create(file)
497 .with_context(|| format!("cannot create file {file:?}"))?
498 .write_all(template_str.as_bytes())
499 .context("could not write file")?;
500 } else {
501 println!("{}", &template_str);
502 }
503 }
504 ValidatorCmd::Definition(DefinitionCmd::Fetch { file }) => {
505 let identity_key = IdentityKey(fvk.spend_verification_key().clone().into());
506 super::query::ValidatorCmd::Definition {
507 file: file.clone(),
508 identity_key: identity_key.to_string(),
509 }
510 .exec(app)
511 .await?;
512 }
513 }
514
515 Ok(())
516 }
517}
518
519fn generate_new_tendermint_keypair() -> anyhow::Result<tendermint::PrivateKey> {
521 let signing_key = ed25519_consensus::SigningKey::new(OsRng);
522 let slice_signing_key = signing_key.as_bytes().as_slice();
523 let priv_consensus_key = tendermint::PrivateKey::Ed25519(slice_signing_key.try_into()?);
524 Ok(priv_consensus_key)
525}
526
527fn read_validator_toml(file: &str) -> Result<Validator> {
529 let mut definition_file =
530 File::open(file).with_context(|| format!("cannot open file {file:?}"))?;
531 let mut definition: String = String::new();
532 definition_file
533 .read_to_string(&mut definition)
534 .with_context(|| format!("failed to read file {file:?}"))?;
535 let new_validator: ValidatorToml =
536 toml::from_str(&definition).context("unable to parse validator definition")?;
537 let new_validator: Validator = new_validator
538 .try_into()
539 .context("unable to parse validator definition")?;
540 Ok(new_validator)
541}