pcli/command/
validator.rs

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    /// Display the validator identity key derived from this wallet's spend seed.
30    Identity {
31        /// Use Base64 encoding for the identity key, rather than the default of Bech32.
32        #[clap(long)]
33        base64: bool,
34    },
35    /// Display the validator's governance subkey derived from this wallet's governance seed.
36    GovernanceKey {
37        /// Use Base64 encoding for the governance key, rather than the default of Bech32.
38        #[clap(long)]
39        base64: bool,
40    },
41    /// Manage your validator's definition.
42    #[clap(subcommand)]
43    Definition(DefinitionCmd),
44    /// Submit and sign votes in your capacity as a validator.
45    #[clap(subcommand)]
46    Vote(VoteCmd),
47}
48
49#[derive(Debug, clap::Subcommand)]
50pub enum VoteCmd {
51    /// Cast a vote on a proposal in your capacity as a validator (see also: `pcli tx vote` for
52    /// delegator voting).
53    Cast {
54        /// Optional. Only spend funds originally received by the given account.
55        #[clap(long, default_value = "0", global = true, display_order = 300)]
56        source: u32,
57        /// The vote to cast.
58        #[clap(subcommand)]
59        vote: super::tx::VoteCmd,
60        /// A comment or justification of the vote. Limited to 1 KB.
61        #[clap(long, default_value = "", global = true, display_order = 400)]
62        reason: String,
63        /// Use an externally-provided signature to authorize the vote.
64        ///
65        /// This is useful for offline signing, e.g. in an airgap setup. The signature for the
66        /// vote may be generated using the `pcli validator vote sign` command.
67        #[clap(long, global = true, display_order = 500)]
68        signature: Option<String>,
69        /// Vote on behalf of a particular validator.
70        ///
71        /// This must be specified when the custody backend does not match the validator identity
72        /// key, i.e. when using a separate governance key on another wallet.
73        #[clap(long, global = true, display_order = 600)]
74        validator: Option<IdentityKey>,
75        /// The selected fee tier to multiply the fee amount by.
76        #[clap(short, long, default_value_t)]
77        fee_tier: FeeTier,
78    },
79    /// Sign a vote on a proposal in your capacity as a validator, for submission elsewhere.
80    Sign {
81        /// The vote to sign.
82        #[clap(subcommand)]
83        vote: super::tx::VoteCmd,
84        /// A comment or justification of the vote. Limited to 1 KB.
85        #[clap(long, default_value = "", global = true, display_order = 400)]
86        reason: String,
87        /// The file to write the signature to [default: stdout].
88        #[clap(long, global = true, display_order = 500)]
89        signature_file: Option<String>,
90        /// Vote on behalf of a particular validator.
91        ///
92        /// This must be specified when the custody backend does not match the validator identity
93        /// key, i.e. when using a separate governance key on another wallet.
94        #[clap(long, global = true, display_order = 600)]
95        validator: Option<IdentityKey>,
96    },
97}
98
99#[derive(Debug, clap::Subcommand)]
100pub enum DefinitionCmd {
101    /// Submit a ValidatorDefinition transaction to create or update a validator.
102    Upload {
103        /// The TOML file containing the ValidatorDefinition to upload.
104        #[clap(long)]
105        file: String,
106        /// Optional. Only spend funds originally received by the given account.
107        #[clap(long, default_value = "0")]
108        source: u32,
109        /// Use an externally-provided signature to authorize the validator definition.
110        ///
111        /// This is useful for offline signing, e.g. in an airgap setup. The signature for the
112        /// definition may be generated using the `pcli validator definition sign` command.
113        #[clap(long)]
114        signature: Option<String>,
115        /// The selected fee tier to multiply the fee amount by.
116        #[clap(short, long, default_value_t)]
117        fee_tier: FeeTier,
118    },
119    /// Sign a validator definition offline for submission elsewhere.
120    Sign {
121        /// The TOML file containing the ValidatorDefinition to sign.
122        #[clap(long)]
123        file: String,
124        /// The file to write the signature to [default: stdout].
125        #[clap(long)]
126        signature_file: Option<String>,
127    },
128    /// Generates a template validator definition for editing.
129    ///
130    /// The validator identity field will be prepopulated with the validator
131    /// identity key derived from this wallet's seed phrase.
132    Template {
133        /// The TOML file to write the template to [default: stdout].
134        #[clap(long)]
135        file: Option<String>,
136
137        /// The Tendermint JSON file 'priv_validator_key.json', containing
138        /// the consensus key for the validator identity. If provided,
139        /// the key will be used in the generated validator template.
140        /// If not provided, a random key will be inserted.
141        #[clap(short = 'k', long)]
142        tendermint_validator_keyfile: Option<camino::Utf8PathBuf>,
143    },
144    /// Fetches the definition for your validator
145    Fetch {
146        /// The JSON file to write the definition to [default: stdout].
147        #[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                // Sign the validator definition with the wallet's spend key, or instead attach the
256                // provided signature if present.
257                let auth_sig = if let Some(signature) = signature {
258                    // The user can specify `-` to read the signature from stdin.
259                    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                // Construct a new transaction and include the validator definition.
279
280                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                // Only commit the state if the transaction was submitted
289                // successfully, so that we don't store pending notes that will
290                // never appear on-chain.
291                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                // Construct the vote body
310                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                // Construct the vote body
370                let body = ValidatorVoteBody {
371                    proposal,
372                    vote,
373                    identity_key,
374                    governance_key,
375                    reason: ValidatorVoteReason(reason.clone()),
376                };
377
378                // If the user specified a signature, use it. Otherwise, generate a new signature
379                // using local custody
380                let auth_sig = if let Some(signature) = signature {
381                    // The user can specify `-` to read the signature from stdin.
382                    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                // Construct a new transaction and include the validator definition.
401                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                // By default, the template sets the governance key to the same verification key as
419                // the identity key, but a validator can change this if they want to use different
420                // key material.
421                let governance_key = app.config.governance_key();
422
423                // Honor the filepath to `priv_validator_key.json`, if set. Otherwise, generate
424                // a random pubkey and emit a warning about it.
425                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                // Customize the human-readable comment text in the definition.
444                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                    // Default enabled to "false" so operators are required to manually
462                    // enable their validators when ready.
463                    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
519/// Generate a new ED25519 keypair for use with Tendermint.
520fn 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
527/// Parse a validator definition TOML file and return the parsed definition.
528fn 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}