From 76c80d9573c0a2c01838b73a0f5022b18bbcc7de Mon Sep 17 00:00:00 2001 From: kirill lykov Date: Tue, 19 Apr 2022 11:48:34 +0200 Subject: [PATCH] Add clap-v3-utils (#24096) * Add clap-utils-v3 * update Cargo.lock * address PR comment: rename crate * address PR comment: rename to clap-v3-utils * update dep version * fix clippy errors * update Cargo.lock --- Cargo.lock | 17 + Cargo.toml | 1 + clap-v3-utils/Cargo.toml | 31 + clap-v3-utils/src/fee_payer.rs | 21 + clap-v3-utils/src/input_parsers.rs | 374 +++++++ clap-v3-utils/src/input_validators.rs | 416 ++++++++ clap-v3-utils/src/keypair.rs | 1329 +++++++++++++++++++++++++ clap-v3-utils/src/lib.rs | 32 + clap-v3-utils/src/memo.rs | 15 + clap-v3-utils/src/nonce.rs | 52 + clap-v3-utils/src/offline.rs | 99 ++ 11 files changed, 2387 insertions(+) create mode 100644 clap-v3-utils/Cargo.toml create mode 100644 clap-v3-utils/src/fee_payer.rs create mode 100644 clap-v3-utils/src/input_parsers.rs create mode 100644 clap-v3-utils/src/input_validators.rs create mode 100644 clap-v3-utils/src/keypair.rs create mode 100644 clap-v3-utils/src/lib.rs create mode 100644 clap-v3-utils/src/memo.rs create mode 100644 clap-v3-utils/src/nonce.rs create mode 100644 clap-v3-utils/src/offline.rs diff --git a/Cargo.lock b/Cargo.lock index 1616c9de7..3419068cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4547,6 +4547,23 @@ dependencies = [ "url 2.2.2", ] +[[package]] +name = "solana-clap-v3-utils" +version = "1.11.0" +dependencies = [ + "chrono", + "clap 3.1.8", + "rpassword", + "solana-perf", + "solana-remote-wallet", + "solana-sdk", + "tempfile", + "thiserror", + "tiny-bip39", + "uriparse", + "url 2.2.2", +] + [[package]] name = "solana-cli" version = "1.11.0" diff --git a/Cargo.toml b/Cargo.toml index e3ffba08b..69edcb50e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "bloom", "bucket_map", "clap-utils", + "clap-v3-utils", "cli", "cli-config", "cli-output", diff --git a/clap-v3-utils/Cargo.toml b/clap-v3-utils/Cargo.toml new file mode 100644 index 000000000..aa56c1606 --- /dev/null +++ b/clap-v3-utils/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "solana-clap-v3-utils" +version = "1.11.0" +description = "Solana utilities for the clap v3" +authors = ["Solana Maintainers "] +repository = "https://github.com/solana-labs/solana" +license = "Apache-2.0" +homepage = "https://solana.com/" +documentation = "https://docs.rs/solana-clap-utils" +edition = "2021" + +[dependencies] +chrono = "0.4" +clap = { version = "3.1.5", features = ["cargo"] } +rpassword = "6.0" +solana-perf = { path = "../perf", version = "=1.11.0" } +solana-remote-wallet = { path = "../remote-wallet", version = "=1.11.0", default-features = false } +solana-sdk = { path = "../sdk", version = "=1.11.0" } +thiserror = "1.0.30" +tiny-bip39 = "0.8.2" +uriparse = "0.6.4" +url = "2.2.2" + +[dev-dependencies] +tempfile = "3.3.0" + +[lib] +name = "solana_clap_v3_utils" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/clap-v3-utils/src/fee_payer.rs b/clap-v3-utils/src/fee_payer.rs new file mode 100644 index 000000000..c87b3dcd7 --- /dev/null +++ b/clap-v3-utils/src/fee_payer.rs @@ -0,0 +1,21 @@ +use { + crate::{input_validators, ArgConstant}, + clap::Arg, +}; + +pub const FEE_PAYER_ARG: ArgConstant<'static> = ArgConstant { + name: "fee_payer", + long: "fee-payer", + help: "Specify the fee-payer account. This may be a keypair file, the ASK keyword \n\ + or the pubkey of an offline signer, provided an appropriate --signer argument \n\ + is also passed. Defaults to the client keypair.", +}; + +pub fn fee_payer_arg<'a>() -> Arg<'a> { + Arg::new(FEE_PAYER_ARG.name) + .long(FEE_PAYER_ARG.long) + .takes_value(true) + .value_name("KEYPAIR") + .validator(|s| input_validators::is_valid_signer(s)) + .help(FEE_PAYER_ARG.help) +} diff --git a/clap-v3-utils/src/input_parsers.rs b/clap-v3-utils/src/input_parsers.rs new file mode 100644 index 000000000..53972f625 --- /dev/null +++ b/clap-v3-utils/src/input_parsers.rs @@ -0,0 +1,374 @@ +use { + crate::keypair::{ + keypair_from_seed_phrase, pubkey_from_path, resolve_signer_from_path, signer_from_path, + ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG, + }, + chrono::DateTime, + clap::ArgMatches, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_sdk::{ + clock::UnixTimestamp, + commitment_config::CommitmentConfig, + genesis_config::ClusterType, + native_token::sol_to_lamports, + pubkey::Pubkey, + signature::{read_keypair_file, Keypair, Signature, Signer}, + }, + std::{str::FromStr, sync::Arc}, +}; + +// Sentinel value used to indicate to write to screen instead of file +pub const STDOUT_OUTFILE_TOKEN: &str = "-"; + +// Return parsed values from matches at `name` +pub fn values_of(matches: &ArgMatches, name: &str) -> Option> +where + T: std::str::FromStr, + ::Err: std::fmt::Debug, +{ + matches + .values_of(name) + .map(|xs| xs.map(|x| x.parse::().unwrap()).collect()) +} + +// Return a parsed value from matches at `name` +pub fn value_of(matches: &ArgMatches, name: &str) -> Option +where + T: std::str::FromStr, + ::Err: std::fmt::Debug, +{ + if let Some(value) = matches.value_of(name) { + value.parse::().ok() + } else { + None + } +} + +pub fn unix_timestamp_from_rfc3339_datetime( + matches: &ArgMatches, + name: &str, +) -> Option { + matches.value_of(name).and_then(|value| { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|date_time| date_time.timestamp()) + }) +} + +// Return the keypair for an argument with filename `name` or None if not present. +pub fn keypair_of(matches: &ArgMatches, name: &str) -> Option { + if let Some(value) = matches.value_of(name) { + if value == ASK_KEYWORD { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + keypair_from_seed_phrase(name, skip_validation, true, None, true).ok() + } else { + read_keypair_file(value).ok() + } + } else { + None + } +} + +pub fn keypairs_of(matches: &ArgMatches, name: &str) -> Option> { + matches.values_of(name).map(|values| { + values + .filter_map(|value| { + if value == ASK_KEYWORD { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + keypair_from_seed_phrase(name, skip_validation, true, None, true).ok() + } else { + read_keypair_file(value).ok() + } + }) + .collect() + }) +} + +// Return a pubkey for an argument that can itself be parsed into a pubkey, +// or is a filename that can be read as a keypair +pub fn pubkey_of(matches: &ArgMatches, name: &str) -> Option { + value_of(matches, name).or_else(|| keypair_of(matches, name).map(|keypair| keypair.pubkey())) +} + +pub fn pubkeys_of(matches: &ArgMatches, name: &str) -> Option> { + matches.values_of(name).map(|values| { + values + .map(|value| { + value.parse::().unwrap_or_else(|_| { + read_keypair_file(value) + .expect("read_keypair_file failed") + .pubkey() + }) + }) + .collect() + }) +} + +// Return pubkey/signature pairs for a string of the form pubkey=signature +pub fn pubkeys_sigs_of(matches: &ArgMatches, name: &str) -> Option> { + matches.values_of(name).map(|values| { + values + .map(|pubkey_signer_string| { + let mut signer = pubkey_signer_string.split('='); + let key = Pubkey::from_str(signer.next().unwrap()).unwrap(); + let sig = Signature::from_str(signer.next().unwrap()).unwrap(); + (key, sig) + }) + .collect() + }) +} + +// Return a signer from matches at `name` +#[allow(clippy::type_complexity)] +pub fn signer_of( + matches: &ArgMatches, + name: &str, + wallet_manager: &mut Option>, +) -> Result<(Option>, Option), Box> { + if let Some(location) = matches.value_of(name) { + let signer = signer_from_path(matches, location, name, wallet_manager)?; + let signer_pubkey = signer.pubkey(); + Ok((Some(signer), Some(signer_pubkey))) + } else { + Ok((None, None)) + } +} + +pub fn pubkey_of_signer( + matches: &ArgMatches, + name: &str, + wallet_manager: &mut Option>, +) -> Result, Box> { + if let Some(location) = matches.value_of(name) { + Ok(Some(pubkey_from_path( + matches, + location, + name, + wallet_manager, + )?)) + } else { + Ok(None) + } +} + +pub fn pubkeys_of_multiple_signers( + matches: &ArgMatches, + name: &str, + wallet_manager: &mut Option>, +) -> Result>, Box> { + if let Some(pubkey_matches) = matches.values_of(name) { + let mut pubkeys: Vec = vec![]; + for signer in pubkey_matches { + pubkeys.push(pubkey_from_path(matches, signer, name, wallet_manager)?); + } + Ok(Some(pubkeys)) + } else { + Ok(None) + } +} + +pub fn resolve_signer( + matches: &ArgMatches, + name: &str, + wallet_manager: &mut Option>, +) -> Result, Box> { + resolve_signer_from_path( + matches, + matches.value_of(name).unwrap(), + name, + wallet_manager, + ) +} + +pub fn lamports_of_sol(matches: &ArgMatches, name: &str) -> Option { + value_of(matches, name).map(sol_to_lamports) +} + +pub fn cluster_type_of(matches: &ArgMatches, name: &str) -> Option { + value_of(matches, name) +} + +pub fn commitment_of(matches: &ArgMatches, name: &str) -> Option { + matches + .value_of(name) + .map(|value| CommitmentConfig::from_str(value).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use { + super::*, + clap::{Arg, Command}, + solana_sdk::signature::write_keypair_file, + std::fs, + }; + + fn app<'ab>() -> Command<'ab> { + Command::new("test") + .arg( + Arg::new("multiple") + .long("multiple") + .takes_value(true) + .multiple_occurrences(true) + .multiple_values(true), + ) + .arg(Arg::new("single").takes_value(true).long("single")) + .arg(Arg::new("unit").takes_value(true).long("unit")) + } + + fn tmp_file_path(name: &str, pubkey: &Pubkey) -> String { + use std::env; + let out_dir = env::var("FARF_DIR").unwrap_or_else(|_| "farf".to_string()); + + format!("{}/tmp/{}-{}", out_dir, name, pubkey) + } + + #[test] + fn test_values_of() { + let matches = + app() + .clone() + .get_matches_from(vec!["test", "--multiple", "50", "--multiple", "39"]); + assert_eq!(values_of(&matches, "multiple"), Some(vec![50, 39])); + assert_eq!(values_of::(&matches, "single"), None); + + let pubkey0 = solana_sdk::pubkey::new_rand(); + let pubkey1 = solana_sdk::pubkey::new_rand(); + let matches = app().clone().get_matches_from(vec![ + "test", + "--multiple", + &pubkey0.to_string(), + "--multiple", + &pubkey1.to_string(), + ]); + assert_eq!( + values_of(&matches, "multiple"), + Some(vec![pubkey0, pubkey1]) + ); + } + + #[test] + fn test_value_of() { + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "50"]); + assert_eq!(value_of(&matches, "single"), Some(50)); + assert_eq!(value_of::(&matches, "multiple"), None); + + let pubkey = solana_sdk::pubkey::new_rand(); + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", &pubkey.to_string()]); + assert_eq!(value_of(&matches, "single"), Some(pubkey)); + } + + #[test] + fn test_keypair_of() { + let keypair = Keypair::new(); + let outfile = tmp_file_path("test_keypair_of.json", &keypair.pubkey()); + let _ = write_keypair_file(&keypair, &outfile).unwrap(); + + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", &outfile]); + assert_eq!( + keypair_of(&matches, "single").unwrap().pubkey(), + keypair.pubkey() + ); + assert!(keypair_of(&matches, "multiple").is_none()); + + let matches = + app() + .clone() + .get_matches_from(vec!["test", "--single", "random_keypair_file.json"]); + assert!(keypair_of(&matches, "single").is_none()); + + fs::remove_file(&outfile).unwrap(); + } + + #[test] + fn test_pubkey_of() { + let keypair = Keypair::new(); + let outfile = tmp_file_path("test_pubkey_of.json", &keypair.pubkey()); + let _ = write_keypair_file(&keypair, &outfile).unwrap(); + + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", &outfile]); + assert_eq!(pubkey_of(&matches, "single"), Some(keypair.pubkey())); + assert_eq!(pubkey_of(&matches, "multiple"), None); + + let matches = + app() + .clone() + .get_matches_from(vec!["test", "--single", &keypair.pubkey().to_string()]); + assert_eq!(pubkey_of(&matches, "single"), Some(keypair.pubkey())); + + let matches = + app() + .clone() + .get_matches_from(vec!["test", "--single", "random_keypair_file.json"]); + assert_eq!(pubkey_of(&matches, "single"), None); + + fs::remove_file(&outfile).unwrap(); + } + + #[test] + fn test_pubkeys_of() { + let keypair = Keypair::new(); + let outfile = tmp_file_path("test_pubkeys_of.json", &keypair.pubkey()); + let _ = write_keypair_file(&keypair, &outfile).unwrap(); + + let matches = app().clone().get_matches_from(vec![ + "test", + "--multiple", + &keypair.pubkey().to_string(), + "--multiple", + &outfile, + ]); + assert_eq!( + pubkeys_of(&matches, "multiple"), + Some(vec![keypair.pubkey(), keypair.pubkey()]) + ); + fs::remove_file(&outfile).unwrap(); + } + + #[test] + fn test_pubkeys_sigs_of() { + let key1 = solana_sdk::pubkey::new_rand(); + let key2 = solana_sdk::pubkey::new_rand(); + let sig1 = Keypair::new().sign_message(&[0u8]); + let sig2 = Keypair::new().sign_message(&[1u8]); + let signer1 = format!("{}={}", key1, sig1); + let signer2 = format!("{}={}", key2, sig2); + let matches = app().clone().get_matches_from(vec![ + "test", + "--multiple", + &signer1, + "--multiple", + &signer2, + ]); + assert_eq!( + pubkeys_sigs_of(&matches, "multiple"), + Some(vec![(key1, sig1), (key2, sig2)]) + ); + } + + #[test] + fn test_lamports_of_sol() { + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "50"]); + assert_eq!(lamports_of_sol(&matches, "single"), Some(50_000_000_000)); + assert_eq!(lamports_of_sol(&matches, "multiple"), None); + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "1.5"]); + assert_eq!(lamports_of_sol(&matches, "single"), Some(1_500_000_000)); + assert_eq!(lamports_of_sol(&matches, "multiple"), None); + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "0.03"]); + assert_eq!(lamports_of_sol(&matches, "single"), Some(30_000_000)); + } +} diff --git a/clap-v3-utils/src/input_validators.rs b/clap-v3-utils/src/input_validators.rs new file mode 100644 index 000000000..15e8a87a4 --- /dev/null +++ b/clap-v3-utils/src/input_validators.rs @@ -0,0 +1,416 @@ +use { + crate::keypair::{parse_signer_source, SignerSourceKind, ASK_KEYWORD}, + chrono::DateTime, + solana_sdk::{ + clock::{Epoch, Slot}, + hash::Hash, + pubkey::{Pubkey, MAX_SEED_LEN}, + signature::{read_keypair_file, Signature}, + }, + std::{fmt::Display, str::FromStr}, +}; + +fn is_parsable_generic(string: T) -> Result<(), String> +where + T: AsRef + Display, + U: FromStr, + U::Err: Display, +{ + string + .as_ref() + .parse::() + .map(|_| ()) + .map_err(|err| format!("error parsing '{}': {}", string, err)) +} + +// Return an error if string cannot be parsed as type T. +// Takes a String to avoid second type parameter when used as a clap validator +pub fn is_parsable(string: String) -> Result<(), String> +where + T: FromStr, + T::Err: Display, +{ + is_parsable_generic::(string) +} + +// Return an error if string cannot be parsed as numeric type T, and value not within specified +// range +pub fn is_within_range(string: String, range_min: T, range_max: T) -> Result<(), String> +where + T: FromStr + Copy + std::fmt::Debug + PartialOrd + std::ops::Add + From, + T::Err: Display, +{ + match string.parse::() { + Ok(input) => { + let range = range_min..range_max + 1.into(); + if !range.contains(&input) { + Err(format!( + "input '{:?}' out of range ({:?}..{:?}]", + input, range_min, range_max + )) + } else { + Ok(()) + } + } + Err(err) => Err(format!("error parsing '{}': {}", string, err)), + } +} + +// Return an error if a pubkey cannot be parsed. +pub fn is_pubkey(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_parsable_generic::(string) +} + +// Return an error if a hash cannot be parsed. +pub fn is_hash(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_parsable_generic::(string) +} + +// Return an error if a keypair file cannot be parsed. +pub fn is_keypair(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + read_keypair_file(string.as_ref()) + .map(|_| ()) + .map_err(|err| format!("{}", err)) +} + +// Return an error if a keypair file cannot be parsed +pub fn is_keypair_or_ask_keyword(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + if string.as_ref() == ASK_KEYWORD { + return Ok(()); + } + read_keypair_file(string.as_ref()) + .map(|_| ()) + .map_err(|err| format!("{}", err)) +} + +// Return an error if a `SignerSourceKind::Prompt` cannot be parsed +pub fn is_prompt_signer_source(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + if string.as_ref() == ASK_KEYWORD { + return Ok(()); + } + match parse_signer_source(string.as_ref()) + .map_err(|err| format!("{}", err))? + .kind + { + SignerSourceKind::Prompt => Ok(()), + _ => Err(format!( + "Unable to parse input as `prompt:` URI scheme or `ASK` keyword: {}", + string + )), + } +} + +// Return an error if string cannot be parsed as pubkey string or keypair file location +pub fn is_pubkey_or_keypair(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_pubkey(string.as_ref()).or_else(|_| is_keypair(string)) +} + +// Return an error if string cannot be parsed as a pubkey string, or a valid Signer that can +// produce a pubkey() +pub fn is_valid_pubkey(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + match parse_signer_source(string.as_ref()) + .map_err(|err| format!("{}", err))? + .kind + { + SignerSourceKind::Filepath(path) => is_keypair(path), + _ => Ok(()), + } +} + +// Return an error if string cannot be parsed as a valid Signer. This is an alias of +// `is_valid_pubkey`, and does accept pubkey strings, even though a Pubkey is not by itself +// sufficient to sign a transaction. +// +// In the current offline-signing implementation, a pubkey is the valid input for a signer field +// when paired with an offline `--signer` argument to provide a Presigner (pubkey + signature). +// Clap validators can't check multiple fields at once, so the verification that a `--signer` is +// also provided and correct happens in parsing, not in validation. +pub fn is_valid_signer(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_valid_pubkey(string) +} + +// Return an error if string cannot be parsed as pubkey=signature string +pub fn is_pubkey_sig(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + let mut signer = string.as_ref().split('='); + match Pubkey::from_str( + signer + .next() + .ok_or_else(|| "Malformed signer string".to_string())?, + ) { + Ok(_) => { + match Signature::from_str( + signer + .next() + .ok_or_else(|| "Malformed signer string".to_string())?, + ) { + Ok(_) => Ok(()), + Err(err) => Err(format!("{}", err)), + } + } + Err(err) => Err(format!("{}", err)), + } +} + +// Return an error if a url cannot be parsed. +pub fn is_url(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + match url::Url::parse(string.as_ref()) { + Ok(url) => { + if url.has_host() { + Ok(()) + } else { + Err("no host provided".to_string()) + } + } + Err(err) => Err(format!("{}", err)), + } +} + +pub fn is_url_or_moniker(string: T) -> Result<(), String> +where + T: AsRef + Display, +{ + match url::Url::parse(&normalize_to_url_if_moniker(string.as_ref())) { + Ok(url) => { + if url.has_host() { + Ok(()) + } else { + Err("no host provided".to_string()) + } + } + Err(err) => Err(format!("{}", err)), + } +} + +pub fn normalize_to_url_if_moniker>(url_or_moniker: T) -> String { + match url_or_moniker.as_ref() { + "m" | "mainnet-beta" => "https://api.mainnet-beta.solana.com", + "t" | "testnet" => "https://api.testnet.solana.com", + "d" | "devnet" => "https://api.devnet.solana.com", + "l" | "localhost" => "http://localhost:8899", + url => url, + } + .to_string() +} + +pub fn is_epoch(epoch: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_parsable_generic::(epoch) +} + +pub fn is_slot(slot: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_parsable_generic::(slot) +} + +pub fn is_pow2(bins: T) -> Result<(), String> +where + T: AsRef + Display, +{ + bins.as_ref() + .parse::() + .map_err(|e| format!("Unable to parse, provided: {}, err: {}", bins, e)) + .and_then(|v| { + if !v.is_power_of_two() { + Err(format!("Must be a power of 2: {}", v)) + } else { + Ok(()) + } + }) +} + +pub fn is_port(port: T) -> Result<(), String> +where + T: AsRef + Display, +{ + is_parsable_generic::(port) +} + +pub fn is_valid_percentage(percentage: T) -> Result<(), String> +where + T: AsRef + Display, +{ + percentage + .as_ref() + .parse::() + .map_err(|e| { + format!( + "Unable to parse input percentage, provided: {}, err: {}", + percentage, e + ) + }) + .and_then(|v| { + if v > 100 { + Err(format!( + "Percentage must be in range of 0 to 100, provided: {}", + v + )) + } else { + Ok(()) + } + }) +} + +pub fn is_amount(amount: T) -> Result<(), String> +where + T: AsRef + Display, +{ + if amount.as_ref().parse::().is_ok() || amount.as_ref().parse::().is_ok() { + Ok(()) + } else { + Err(format!( + "Unable to parse input amount as integer or float, provided: {}", + amount + )) + } +} + +pub fn is_amount_or_all(amount: T) -> Result<(), String> +where + T: AsRef + Display, +{ + if amount.as_ref().parse::().is_ok() + || amount.as_ref().parse::().is_ok() + || amount.as_ref() == "ALL" + { + Ok(()) + } else { + Err(format!( + "Unable to parse input amount as integer or float, provided: {}", + amount + )) + } +} + +pub fn is_rfc3339_datetime(value: T) -> Result<(), String> +where + T: AsRef + Display, +{ + DateTime::parse_from_rfc3339(value.as_ref()) + .map(|_| ()) + .map_err(|e| format!("{}", e)) +} + +pub fn is_derivation(value: T) -> Result<(), String> +where + T: AsRef + Display, +{ + let value = value.as_ref().replace('\'', ""); + let mut parts = value.split('/'); + let account = parts.next().unwrap(); + account + .parse::() + .map_err(|e| { + format!( + "Unable to parse derivation, provided: {}, err: {}", + account, e + ) + }) + .and_then(|_| { + if let Some(change) = parts.next() { + change.parse::().map_err(|e| { + format!( + "Unable to parse derivation, provided: {}, err: {}", + change, e + ) + }) + } else { + Ok(0) + } + }) + .map(|_| ()) +} + +pub fn is_derived_address_seed(value: T) -> Result<(), String> +where + T: AsRef + Display, +{ + let value = value.as_ref(); + if value.len() > MAX_SEED_LEN { + Err(format!( + "Address seed must not be longer than {} bytes", + MAX_SEED_LEN + )) + } else { + Ok(()) + } +} + +pub fn is_niceness_adjustment_valid(value: T) -> Result<(), String> +where + T: AsRef + Display, +{ + let adjustment = value.as_ref().parse::().map_err(|err| { + format!( + "error parsing niceness adjustment value '{}': {}", + value, err + ) + })?; + if solana_perf::thread::is_renice_allowed(adjustment) { + Ok(()) + } else { + Err(String::from( + "niceness adjustment supported only on Linux; negative adjustment \ + (priority increase) requires root or CAP_SYS_NICE (see `man 7 capabilities` \ + for details)", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_derivation() { + assert_eq!(is_derivation("2"), Ok(())); + assert_eq!(is_derivation("0"), Ok(())); + assert_eq!(is_derivation("65537"), Ok(())); + assert_eq!(is_derivation("0/2"), Ok(())); + assert_eq!(is_derivation("0'/2'"), Ok(())); + assert!(is_derivation("a").is_err()); + assert!(is_derivation("4294967296").is_err()); + assert!(is_derivation("a/b").is_err()); + assert!(is_derivation("0/4294967296").is_err()); + } + + #[test] + fn test_is_niceness_adjustment_valid() { + assert_eq!(is_niceness_adjustment_valid("0"), Ok(())); + assert!(is_niceness_adjustment_valid("128").is_err()); + assert!(is_niceness_adjustment_valid("-129").is_err()); + } +} diff --git a/clap-v3-utils/src/keypair.rs b/clap-v3-utils/src/keypair.rs new file mode 100644 index 000000000..fd7dd6d50 --- /dev/null +++ b/clap-v3-utils/src/keypair.rs @@ -0,0 +1,1329 @@ +//! Loading signers and keypairs from the command line. +//! +//! This module contains utilities for loading [Signer]s and [Keypair]s from +//! standard signing sources, from the command line, as in the Solana CLI. +//! +//! The key function here is [`signer_from_path`], which loads a `Signer` from +//! one of several possible sources by interpreting a "path" command line +//! argument. Its documentation includes a description of all possible signing +//! sources supported by the Solana CLI. Many other functions here are +//! variations on, or delegate to, `signer_from_path`. + +use { + crate::{ + input_parsers::{pubkeys_sigs_of, STDOUT_OUTFILE_TOKEN}, + offline::{SIGNER_ARG, SIGN_ONLY_ARG}, + ArgConstant, + }, + bip39::{Language, Mnemonic, Seed}, + clap::ArgMatches, + rpassword::prompt_password, + solana_remote_wallet::{ + locator::{Locator as RemoteWalletLocator, LocatorError as RemoteWalletLocatorError}, + remote_keypair::generate_remote_keypair, + remote_wallet::{maybe_wallet_manager, RemoteWalletError, RemoteWalletManager}, + }, + solana_sdk::{ + derivation_path::{DerivationPath, DerivationPathError}, + hash::Hash, + message::Message, + pubkey::Pubkey, + signature::{ + generate_seed_from_seed_phrase_and_passphrase, keypair_from_seed, + keypair_from_seed_and_derivation_path, keypair_from_seed_phrase_and_passphrase, + read_keypair, read_keypair_file, Keypair, NullSigner, Presigner, Signature, Signer, + }, + }, + std::{ + cell::RefCell, + convert::TryFrom, + error, + io::{stdin, stdout, Write}, + ops::Deref, + process::exit, + str::FromStr, + sync::Arc, + }, + thiserror::Error, +}; + +pub struct SignOnly { + pub blockhash: Hash, + pub message: Option, + pub present_signers: Vec<(Pubkey, Signature)>, + pub absent_signers: Vec, + pub bad_signers: Vec, +} + +impl SignOnly { + pub fn has_all_signers(&self) -> bool { + self.absent_signers.is_empty() && self.bad_signers.is_empty() + } + + pub fn presigner_of(&self, pubkey: &Pubkey) -> Option { + presigner_from_pubkey_sigs(pubkey, &self.present_signers) + } +} +pub type CliSigners = Vec>; +pub type SignerIndex = usize; +pub struct CliSignerInfo { + pub signers: CliSigners, +} + +impl CliSignerInfo { + pub fn index_of(&self, pubkey: Option) -> Option { + if let Some(pubkey) = pubkey { + self.signers + .iter() + .position(|signer| signer.pubkey() == pubkey) + } else { + Some(0) + } + } + pub fn index_of_or_none(&self, pubkey: Option) -> Option { + if let Some(pubkey) = pubkey { + self.signers + .iter() + .position(|signer| signer.pubkey() == pubkey) + } else { + None + } + } + pub fn signers_for_message(&self, message: &Message) -> Vec<&dyn Signer> { + self.signers + .iter() + .filter_map(|k| { + if message.signer_keys().contains(&&k.pubkey()) { + Some(k.as_ref()) + } else { + None + } + }) + .collect() + } +} + +/// A command line argument that loads a default signer in absence of other signers. +/// +/// This type manages a default signing source which may be overridden by other +/// signing sources via its [`generate_unique_signers`] method. +/// +/// [`generate_unique_signers`]: DefaultSigner::generate_unique_signers +/// +/// `path` is a signing source as documented by [`signer_from_path`], and +/// `arg_name` is the name of its [clap] command line argument, which is passed +/// to `signer_from_path` as its `keypair_name` argument. +#[derive(Debug, Default)] +pub struct DefaultSigner { + /// The name of the signers command line argument. + pub arg_name: String, + /// The signing source. + pub path: String, + is_path_checked: RefCell, +} + +impl DefaultSigner { + /// Create a new `DefaultSigner`. + /// + /// `path` is a signing source as documented by [`signer_from_path`], and + /// `arg_name` is the name of its [clap] command line argument, which is + /// passed to `signer_from_path` as its `keypair_name` argument. + /// + /// [clap]: https://docs.rs/clap + /// + /// # Examples + /// + /// ```no_run + /// use clap::{Arg, Command}; + /// use solana_clap_v3_utils::keypair::DefaultSigner; + /// use solana_clap_v3_utils::offline::OfflineArgs; + /// + /// let clap_app = Command::new("my-program") + /// // The argument we'll parse as a signer "path" + /// .arg(Arg::new("keypair") + /// .required(true) + /// .help("The default signer")) + /// .offline_args(); + /// + /// let clap_matches = clap_app.get_matches(); + /// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); + /// + /// let default_signer = DefaultSigner::new("keypair", &keypair_str); + /// # assert!(default_signer.arg_name.len() > 0); + /// assert_eq!(default_signer.path, keypair_str); + /// # Ok::<(), Box>(()) + /// ``` + pub fn new, P: AsRef>(arg_name: AN, path: P) -> Self { + let arg_name = arg_name.as_ref().to_string(); + let path = path.as_ref().to_string(); + Self { + arg_name, + path, + ..Self::default() + } + } + + fn path(&self) -> Result<&str, Box> { + if !self.is_path_checked.borrow().deref() { + parse_signer_source(&self.path) + .and_then(|s| { + if let SignerSourceKind::Filepath(path) = &s.kind { + std::fs::metadata(path).map(|_| ()).map_err(|e| e.into()) + } else { + Ok(()) + } + }) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "No default signer found, run \"solana-keygen new -o {}\" to create a new one", + self.path + ), + ) + })?; + *self.is_path_checked.borrow_mut() = true; + } + Ok(&self.path) + } + + /// Generate a unique set of signers, possibly excluding this default signer. + /// + /// This function allows a command line application to have a default + /// signer, perhaps representing a default wallet, but to override that + /// signer and instead sign with one or more other signers. + /// + /// `bulk_signers` is a vector of signers, all of which are optional. If any + /// of those signers is `None`, then the default signer will be loaded; if + /// all of those signers are `Some`, then the default signer will not be + /// loaded. + /// + /// The returned value includes all of the `bulk_signers` that were not + /// `None`, and maybe the default signer, if it was loaded. + /// + /// # Examples + /// + /// ```no_run + /// use clap::{Arg, Command}; + /// use solana_clap_v3_utils::keypair::{DefaultSigner, signer_from_path}; + /// use solana_clap_v3_utils::offline::OfflineArgs; + /// use solana_sdk::signer::Signer; + /// + /// let clap_app = Command::new("my-program") + /// // The argument we'll parse as a signer "path" + /// .arg(Arg::new("keypair") + /// .required(true) + /// .help("The default signer")) + /// .arg(Arg::new("payer") + /// .long("payer") + /// .help("The account paying for the transaction")) + /// .offline_args(); + /// + /// let mut wallet_manager = None; + /// + /// let clap_matches = clap_app.get_matches(); + /// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); + /// let maybe_payer = clap_matches.value_of("payer"); + /// + /// let default_signer = DefaultSigner::new("keypair", &keypair_str); + /// let maybe_payer_signer = maybe_payer.map(|payer| { + /// signer_from_path(&clap_matches, payer, "payer", &mut wallet_manager) + /// }).transpose()?; + /// let bulk_signers = vec![maybe_payer_signer]; + /// + /// let unique_signers = default_signer.generate_unique_signers( + /// bulk_signers, + /// &clap_matches, + /// &mut wallet_manager, + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn generate_unique_signers( + &self, + bulk_signers: Vec>>, + matches: &ArgMatches, + wallet_manager: &mut Option>, + ) -> Result> { + let mut unique_signers = vec![]; + + // Determine if the default signer is needed + if bulk_signers.iter().any(|signer| signer.is_none()) { + let default_signer = self.signer_from_path(matches, wallet_manager)?; + unique_signers.push(default_signer); + } + + for signer in bulk_signers.into_iter().flatten() { + if !unique_signers.iter().any(|s| s == &signer) { + unique_signers.push(signer); + } + } + Ok(CliSignerInfo { + signers: unique_signers, + }) + } + + /// Loads the default [Signer] from one of several possible sources. + /// + /// The `path` is not strictly a file system path, but is interpreted as + /// various types of _signing source_, depending on its format, one of which + /// is a path to a keypair file. Some sources may require user interaction + /// in the course of calling this function. + /// + /// This simply delegates to the [`signer_from_path`] free function, passing + /// it the `DefaultSigner`s `path` and `arg_name` fields as the `path` and + /// `keypair_name` arguments. + /// + /// See the [`signer_from_path`] free function for full documentation of how + /// this function interprets its arguments. + /// + /// # Examples + /// + /// ```no_run + /// use clap::{Arg, Command}; + /// use solana_clap_v3_utils::keypair::DefaultSigner; + /// use solana_clap_v3_utils::offline::OfflineArgs; + /// + /// let clap_app = Command::new("my-program") + /// // The argument we'll parse as a signer "path" + /// .arg(Arg::new("keypair") + /// .required(true) + /// .help("The default signer")) + /// .offline_args(); + /// + /// let clap_matches = clap_app.get_matches(); + /// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); + /// let default_signer = DefaultSigner::new("keypair", &keypair_str); + /// let mut wallet_manager = None; + /// + /// let signer = default_signer.signer_from_path( + /// &clap_matches, + /// &mut wallet_manager, + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn signer_from_path( + &self, + matches: &ArgMatches, + wallet_manager: &mut Option>, + ) -> Result, Box> { + signer_from_path(matches, self.path()?, &self.arg_name, wallet_manager) + } + + /// Loads the default [Signer] from one of several possible sources. + /// + /// The `path` is not strictly a file system path, but is interpreted as + /// various types of _signing source_, depending on its format, one of which + /// is a path to a keypair file. Some sources may require user interaction + /// in the course of calling this function. + /// + /// This simply delegates to the [`signer_from_path_with_config`] free + /// function, passing it the `DefaultSigner`s `path` and `arg_name` fields + /// as the `path` and `keypair_name` arguments. + /// + /// See the [`signer_from_path`] free function for full documentation of how + /// this function interprets its arguments. + /// + /// # Examples + /// + /// ```no_run + /// use clap::{Arg, Command}; + /// use solana_clap_v3_utils::keypair::{SignerFromPathConfig, DefaultSigner}; + /// use solana_clap_v3_utils::offline::OfflineArgs; + /// + /// let clap_app = Command::new("my-program") + /// // The argument we'll parse as a signer "path" + /// .arg(Arg::new("keypair") + /// .required(true) + /// .help("The default signer")) + /// .offline_args(); + /// + /// let clap_matches = clap_app.get_matches(); + /// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); + /// let default_signer = DefaultSigner::new("keypair", &keypair_str); + /// let mut wallet_manager = None; + /// + /// // Allow pubkey signers without accompanying signatures + /// let config = SignerFromPathConfig { + /// allow_null_signer: true, + /// }; + /// + /// let signer = default_signer.signer_from_path_with_config( + /// &clap_matches, + /// &mut wallet_manager, + /// &config, + /// )?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn signer_from_path_with_config( + &self, + matches: &ArgMatches, + wallet_manager: &mut Option>, + config: &SignerFromPathConfig, + ) -> Result, Box> { + signer_from_path_with_config( + matches, + self.path()?, + &self.arg_name, + wallet_manager, + config, + ) + } +} + +pub(crate) struct SignerSource { + pub kind: SignerSourceKind, + pub derivation_path: Option, + pub legacy: bool, +} + +impl SignerSource { + fn new(kind: SignerSourceKind) -> Self { + Self { + kind, + derivation_path: None, + legacy: false, + } + } + + fn new_legacy(kind: SignerSourceKind) -> Self { + Self { + kind, + derivation_path: None, + legacy: true, + } + } +} + +const SIGNER_SOURCE_PROMPT: &str = "prompt"; +const SIGNER_SOURCE_FILEPATH: &str = "file"; +const SIGNER_SOURCE_USB: &str = "usb"; +const SIGNER_SOURCE_STDIN: &str = "stdin"; +const SIGNER_SOURCE_PUBKEY: &str = "pubkey"; + +pub(crate) enum SignerSourceKind { + Prompt, + Filepath(String), + Usb(RemoteWalletLocator), + Stdin, + Pubkey(Pubkey), +} + +impl AsRef for SignerSourceKind { + fn as_ref(&self) -> &str { + match self { + Self::Prompt => SIGNER_SOURCE_PROMPT, + Self::Filepath(_) => SIGNER_SOURCE_FILEPATH, + Self::Usb(_) => SIGNER_SOURCE_USB, + Self::Stdin => SIGNER_SOURCE_STDIN, + Self::Pubkey(_) => SIGNER_SOURCE_PUBKEY, + } + } +} + +impl std::fmt::Debug for SignerSourceKind { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let s: &str = self.as_ref(); + write!(f, "{}", s) + } +} + +#[derive(Debug, Error)] +pub(crate) enum SignerSourceError { + #[error("unrecognized signer source")] + UnrecognizedSource, + #[error(transparent)] + RemoteWalletLocatorError(#[from] RemoteWalletLocatorError), + #[error(transparent)] + DerivationPathError(#[from] DerivationPathError), + #[error(transparent)] + IoError(#[from] std::io::Error), +} + +pub(crate) fn parse_signer_source>( + source: S, +) -> Result { + let source = source.as_ref(); + let source = { + #[cfg(target_family = "windows")] + { + // trim matched single-quotes since cmd.exe won't + let mut source = source; + while let Some(trimmed) = source.strip_prefix('\'') { + source = if let Some(trimmed) = trimmed.strip_suffix('\'') { + trimmed + } else { + break; + } + } + source.replace("\\", "/") + } + #[cfg(not(target_family = "windows"))] + { + source.to_string() + } + }; + match uriparse::URIReference::try_from(source.as_str()) { + Err(_) => Err(SignerSourceError::UnrecognizedSource), + Ok(uri) => { + if let Some(scheme) = uri.scheme() { + let scheme = scheme.as_str().to_ascii_lowercase(); + match scheme.as_str() { + SIGNER_SOURCE_PROMPT => Ok(SignerSource { + kind: SignerSourceKind::Prompt, + derivation_path: DerivationPath::from_uri_any_query(&uri)?, + legacy: false, + }), + SIGNER_SOURCE_FILEPATH => Ok(SignerSource::new(SignerSourceKind::Filepath( + uri.path().to_string(), + ))), + SIGNER_SOURCE_USB => Ok(SignerSource { + kind: SignerSourceKind::Usb(RemoteWalletLocator::new_from_uri(&uri)?), + derivation_path: DerivationPath::from_uri_key_query(&uri)?, + legacy: false, + }), + SIGNER_SOURCE_STDIN => Ok(SignerSource::new(SignerSourceKind::Stdin)), + _ => { + #[cfg(target_family = "windows")] + // On Windows, an absolute path's drive letter will be parsed as the URI + // scheme. Assume a filepath source in case of a single character shceme. + if scheme.len() == 1 { + return Ok(SignerSource::new(SignerSourceKind::Filepath(source))); + } + Err(SignerSourceError::UnrecognizedSource) + } + } + } else { + match source.as_str() { + STDOUT_OUTFILE_TOKEN => Ok(SignerSource::new(SignerSourceKind::Stdin)), + ASK_KEYWORD => Ok(SignerSource::new_legacy(SignerSourceKind::Prompt)), + _ => match Pubkey::from_str(source.as_str()) { + Ok(pubkey) => Ok(SignerSource::new(SignerSourceKind::Pubkey(pubkey))), + Err(_) => std::fs::metadata(source.as_str()) + .map(|_| SignerSource::new(SignerSourceKind::Filepath(source))) + .map_err(|err| err.into()), + }, + } + } + } + } +} + +pub fn presigner_from_pubkey_sigs( + pubkey: &Pubkey, + signers: &[(Pubkey, Signature)], +) -> Option { + signers.iter().find_map(|(signer, sig)| { + if *signer == *pubkey { + Some(Presigner::new(signer, sig)) + } else { + None + } + }) +} + +#[derive(Debug, Default)] +pub struct SignerFromPathConfig { + pub allow_null_signer: bool, +} + +/// Loads a [Signer] from one of several possible sources. +/// +/// The `path` is not strictly a file system path, but is interpreted as various +/// types of _signing source_, depending on its format, one of which is a path +/// to a keypair file. Some sources may require user interaction in the course +/// of calling this function. +/// +/// The result of this function is a boxed object of the [Signer] trait. To load +/// a concrete [Keypair], use the [keypair_from_path] function, though note that +/// it does not support all signer sources. +/// +/// The `matches` argument is the same set of parsed [clap] matches from which +/// `path` was parsed. It is used to parse various additional command line +/// arguments, depending on which signing source is requested, as described +/// below in "Signing sources". +/// +/// [clap]: https//docs.rs/clap +/// +/// The `keypair_name` argument is the "name" of the signer, and is typically +/// the name of the clap argument from which the `path` argument was parsed, +/// like "keypair", "from", or "fee-payer". It is used solely for interactively +/// prompting the user, either when entering seed phrases or selecting from +/// multiple hardware wallets. +/// +/// The `wallet_manager` is used for establishing connections to a hardware +/// device such as Ledger. If `wallet_manager` is a reference to `None`, and a +/// hardware signer is requested, then this function will attempt to create a +/// wallet manager, assigning it to the mutable `wallet_manager` reference. This +/// argument is typically a reference to `None`. +/// +/// # Signing sources +/// +/// The `path` argument can simply be a path to a keypair file, but it may also +/// be interpreted in several other ways, in the following order. +/// +/// Firstly, the `path` argument may be interpreted as a [URI], with the URI +/// scheme indicating where to load the signer from. If it parses as a URI, then +/// the following schemes are supported: +/// +/// - `file:` — Read the keypair from a JSON keypair file. The path portion +/// of the URI is the file path. +/// +/// - `stdin:` — Read the keypair from stdin, in the JSON format used by +/// the keypair file. +/// +/// Non-scheme parts of the URI are ignored. +/// +/// - `prompt:` — The user will be prompted at the command line +/// for their seed phrase and passphrase. +/// +/// In this URI the [query string][qs] may contain zero or one of the +/// following key/value pairs that determine the [BIP44 derivation path][dp] +/// of the private key from the seed: +/// +/// - `key` — In this case the value is either one or two numerical +/// indexes separated by a slash, which represent the "account", and +/// "change" components of the BIP44 derivation path. Example: `key=0/0`. +/// +/// - `full-path` — In this case the value is a full derivation path, +/// and the user is responsible for ensuring it is correct. Example: +/// `full-path=m/44/501/0/0/0`. +/// +/// If neither is provided, then the default derivation path is used. +/// +/// Note that when specifying derivation paths, this routine will convert all +/// indexes into ["hardened"] indexes, even if written as "normal" indexes. +/// +/// Other components of the URI besides the scheme and query string are ignored. +/// +/// If the "skip_seed_phrase_validation" argument, as defined in +/// [SKIP_SEED_PHRASE_VALIDATION_ARG] is found in `matches`, then the keypair +/// seed will be generated directly from the seed phrase, without parsing or +/// validating it as a BIP39 seed phrase. This allows the use of non-BIP39 seed +/// phrases. +/// +/// - `usb:` — Use a USB hardware device as the signer. In this case, the +/// URI host indicates the device type, and is required. The only currently valid host +/// value is "ledger". +/// +/// Optionally, the first segment of the URI path indicates the base-58 +/// encoded pubkey of the wallet, and the "account" and "change" indices of +/// the derivation path can be specified with the `key=` query parameter, as +/// with the `prompt:` URI. +/// +/// Examples: +/// +/// - `usb://ledger` +/// - `usb://ledger?key=0/0` +/// - `usb://ledger/9rPVSygg3brqghvdZ6wsL2i5YNQTGhXGdJzF65YxaCQd` +/// - `usb://ledger/9rPVSygg3brqghvdZ6wsL2i5YNQTGhXGdJzF65YxaCQd?key=0/0` +/// +/// Next the `path` argument may be one of the following strings: +/// +/// - `-` — Read the keypair from stdin. This is the same as the `stdin:` +/// URI scheme. +/// +/// - `ASK` — The user will be prompted at the command line for their seed +/// phrase and passphrase. _This uses a legacy key derivation method and should +/// usually be avoided in favor of `prompt:`._ +/// +/// Next, if the `path` argument parses as a base-58 public key, then the signer +/// is created without a private key, but with presigned signatures, each parsed +/// from the additional command line arguments, provided by the `matches` +/// argument. +/// +/// In this case, the remaining command line arguments are searched for clap +/// arguments named "signer", as defined by [SIGNER_ARG], and each is parsed as +/// a key-value pair of the form "pubkey=signature", where `pubkey` is the same +/// base-58 public key, and `signature` is a serialized signature produced by +/// the corresponding keypair. One of the "signer" signatures must be for the +/// pubkey specified in `path` or this function will return an error; unless the +/// "sign_only" clap argument, as defined by [SIGN_ONLY_ARG], is present in +/// `matches`, in which case the signer will be created with no associated +/// signatures. +/// +/// Finally, if `path`, interpreted as a file path, represents a file on disk, +/// then the signer is created by reading that file as a JSON-serialized +/// keypair. This is the same as the `file:` URI scheme. +/// +/// [qs]: https://en.wikipedia.org/wiki/Query_string +/// [dp]: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki +/// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier +/// ["hardened"]: https://wiki.trezor.io/Hardened_and_non-hardened_derivation +/// +/// # Examples +/// +/// This shows a reasonable way to set up clap to parse all possible signer +/// sources. Note the use of the [`OfflineArgs::offline_args`] method to add +/// correct clap definitions of the `--signer` and `--sign-only` arguments, as +/// required by the base-58 pubkey offline signing method. +/// +/// [`OfflineArgs::offline_args`]: crate::offline::OfflineArgs::offline_args +/// +/// ```no_run +/// use clap::{Arg, Command}; +/// use solana_clap_v3_utils::keypair::signer_from_path; +/// use solana_clap_v3_utils::offline::OfflineArgs; +/// +/// let clap_app = Command::new("my-program") +/// // The argument we'll parse as a signer "path" +/// .arg(Arg::new("keypair") +/// .required(true) +/// .help("The default signer")) +/// .offline_args(); +/// +/// let clap_matches = clap_app.get_matches(); +/// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); +/// let mut wallet_manager = None; +/// let signer = signer_from_path( +/// &clap_matches, +/// &keypair_str, +/// "keypair", +/// &mut wallet_manager, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn signer_from_path( + matches: &ArgMatches, + path: &str, + keypair_name: &str, + wallet_manager: &mut Option>, +) -> Result, Box> { + let config = SignerFromPathConfig::default(); + signer_from_path_with_config(matches, path, keypair_name, wallet_manager, &config) +} + +/// Loads a [Signer] from one of several possible sources. +/// +/// The `path` is not strictly a file system path, but is interpreted as various +/// types of _signing source_, depending on its format, one of which is a path +/// to a keypair file. Some sources may require user interaction in the course +/// of calling this function. +/// +/// This is the same as [`signer_from_path`] except that it additionaolly +/// accepts a [`SignerFromPathConfig`] argument. +/// +/// If the `allow_null_signer` field of `config` is `true`, then pubkey signers +/// are allowed to have zero associated signatures via additional "signer" +/// command line arguments. It the same effect as if the "sign_only" clap +/// argument is present. +/// +/// See [`signer_from_path`] for full documentation of how this function +/// interprets its arguments. +/// +/// # Examples +/// +/// This shows a reasonable way to set up clap to parse all possible signer +/// sources. Note the use of the [`OfflineArgs::offline_args`] method to add +/// correct clap definitions of the `--signer` and `--sign-only` arguments, as +/// required by the base-58 pubkey offline signing method. +/// +/// [`OfflineArgs::offline_args`]: crate::offline::OfflineArgs::offline_args +/// +/// ```no_run +/// use clap::{Arg, Command}; +/// use solana_clap_v3_utils::keypair::{signer_from_path_with_config, SignerFromPathConfig}; +/// use solana_clap_v3_utils::offline::OfflineArgs; +/// +/// let clap_app = Command::new("my-program") +/// // The argument we'll parse as a signer "path" +/// .arg(Arg::new("keypair") +/// .required(true) +/// .help("The default signer")) +/// .offline_args(); +/// +/// let clap_matches = clap_app.get_matches(); +/// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); +/// let mut wallet_manager = None; +/// +/// // Allow pubkey signers without accompanying signatures +/// let config = SignerFromPathConfig { +/// allow_null_signer: true, +/// }; +/// +/// let signer = signer_from_path_with_config( +/// &clap_matches, +/// &keypair_str, +/// "keypair", +/// &mut wallet_manager, +/// &config, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn signer_from_path_with_config( + matches: &ArgMatches, + path: &str, + keypair_name: &str, + wallet_manager: &mut Option>, + config: &SignerFromPathConfig, +) -> Result, Box> { + let SignerSource { + kind, + derivation_path, + legacy, + } = parse_signer_source(path)?; + match kind { + SignerSourceKind::Prompt => { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + Ok(Box::new(keypair_from_seed_phrase( + keypair_name, + skip_validation, + false, + derivation_path, + legacy, + )?)) + } + SignerSourceKind::Filepath(path) => match read_keypair_file(&path) { + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("could not read keypair file \"{}\". Run \"solana-keygen new\" to create a keypair file: {}", path, e), + ) + .into()), + Ok(file) => Ok(Box::new(file)), + }, + SignerSourceKind::Stdin => { + let mut stdin = std::io::stdin(); + Ok(Box::new(read_keypair(&mut stdin)?)) + } + SignerSourceKind::Usb(locator) => { + if wallet_manager.is_none() { + *wallet_manager = maybe_wallet_manager()?; + } + if let Some(wallet_manager) = wallet_manager { + Ok(Box::new(generate_remote_keypair( + locator, + derivation_path.unwrap_or_default(), + wallet_manager, + matches.is_present("confirm_key"), + keypair_name, + )?)) + } else { + Err(RemoteWalletError::NoDeviceFound.into()) + } + } + SignerSourceKind::Pubkey(pubkey) => { + let presigner = pubkeys_sigs_of(matches, SIGNER_ARG.name) + .as_ref() + .and_then(|presigners| presigner_from_pubkey_sigs(&pubkey, presigners)); + if let Some(presigner) = presigner { + Ok(Box::new(presigner)) + } else if config.allow_null_signer || matches.is_present(SIGN_ONLY_ARG.name) { + Ok(Box::new(NullSigner::new(&pubkey))) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("missing signature for supplied pubkey: {}", pubkey), + ) + .into()) + } + } + } +} + +/// Loads the pubkey of a [Signer] from one of several possible sources. +/// +/// The `path` is not strictly a file system path, but is interpreted as various +/// types of _signing source_, depending on its format, one of which is a path +/// to a keypair file. Some sources may require user interaction in the course +/// of calling this function. +/// +/// The only difference between this function and [`signer_from_path`] is in the +/// case of a "pubkey" path: this function does not require that accompanying +/// command line arguments contain an offline signature. +/// +/// See [`signer_from_path`] for full documentation of how this function +/// interprets its arguments. +/// +/// # Examples +/// +/// ```no_run +/// use clap::{Arg, Command}; +/// use solana_clap_v3_utils::keypair::pubkey_from_path; +/// +/// let clap_app = Command::new("my-program") +/// // The argument we'll parse as a signer "path" +/// .arg(Arg::new("keypair") +/// .required(true) +/// .help("The default signer")); +/// +/// let clap_matches = clap_app.get_matches(); +/// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); +/// let mut wallet_manager = None; +/// let pubkey = pubkey_from_path( +/// &clap_matches, +/// &keypair_str, +/// "keypair", +/// &mut wallet_manager, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn pubkey_from_path( + matches: &ArgMatches, + path: &str, + keypair_name: &str, + wallet_manager: &mut Option>, +) -> Result> { + let SignerSource { kind, .. } = parse_signer_source(path)?; + match kind { + SignerSourceKind::Pubkey(pubkey) => Ok(pubkey), + _ => Ok(signer_from_path(matches, path, keypair_name, wallet_manager)?.pubkey()), + } +} + +pub fn resolve_signer_from_path( + matches: &ArgMatches, + path: &str, + keypair_name: &str, + wallet_manager: &mut Option>, +) -> Result, Box> { + let SignerSource { + kind, + derivation_path, + legacy, + } = parse_signer_source(path)?; + match kind { + SignerSourceKind::Prompt => { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + // This method validates the seed phrase, but returns `None` because there is no path + // on disk or to a device + keypair_from_seed_phrase( + keypair_name, + skip_validation, + false, + derivation_path, + legacy, + ) + .map(|_| None) + } + SignerSourceKind::Filepath(path) => match read_keypair_file(&path) { + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "could not read keypair file \"{}\". \ + Run \"solana-keygen new\" to create a keypair file: {}", + path, e + ), + ) + .into()), + Ok(_) => Ok(Some(path.to_string())), + }, + SignerSourceKind::Stdin => { + let mut stdin = std::io::stdin(); + // This method validates the keypair from stdin, but returns `None` because there is no + // path on disk or to a device + read_keypair(&mut stdin).map(|_| None) + } + SignerSourceKind::Usb(locator) => { + if wallet_manager.is_none() { + *wallet_manager = maybe_wallet_manager()?; + } + if let Some(wallet_manager) = wallet_manager { + let path = generate_remote_keypair( + locator, + derivation_path.unwrap_or_default(), + wallet_manager, + matches.is_present("confirm_key"), + keypair_name, + ) + .map(|keypair| keypair.path)?; + Ok(Some(path)) + } else { + Err(RemoteWalletError::NoDeviceFound.into()) + } + } + _ => Ok(Some(path.to_string())), + } +} + +// Keyword used to indicate that the user should be prompted for a keypair seed phrase +pub const ASK_KEYWORD: &str = "ASK"; + +pub const SKIP_SEED_PHRASE_VALIDATION_ARG: ArgConstant<'static> = ArgConstant { + long: "skip-seed-phrase-validation", + name: "skip_seed_phrase_validation", + help: "Skip validation of seed phrases. Use this if your phrase does not use the BIP39 official English word list", +}; + +/// Prompts user for a passphrase and then asks for confirmirmation to check for mistakes +pub fn prompt_passphrase(prompt: &str) -> Result> { + let passphrase = prompt_password(prompt)?; + if !passphrase.is_empty() { + let confirmed = rpassword::prompt_password("Enter same passphrase again: ")?; + if confirmed != passphrase { + return Err("Passphrases did not match".into()); + } + } + Ok(passphrase) +} + +/// Loads a [Keypair] from one of several possible sources. +/// +/// The `path` is not strictly a file system path, but is interpreted as various +/// types of _signing source_, depending on its format, one of which is a path +/// to a keypair file. Some sources may require user interaction in the course +/// of calling this function. +/// +/// This is the same as [`signer_from_path`] except that it only supports +/// signing sources that can result in a [Keypair]: prompt for seed phrase, +/// keypair file, and stdin. +/// +/// If `confirm_pubkey` is `true` then after deriving the pubkey, the user will +/// be prompted to confirm that the pubkey is as expected. +/// +/// See [`signer_from_path`] for full documentation of how this function +/// interprets its arguments. +/// +/// # Examples +/// +/// ```no_run +/// use clap::{Arg, Command}; +/// use solana_clap_v3_utils::keypair::keypair_from_path; +/// +/// let clap_app = Command::new("my-program") +/// // The argument we'll parse as a signer "path" +/// .arg(Arg::new("keypair") +/// .required(true) +/// .help("The default signer")); +/// +/// let clap_matches = clap_app.get_matches(); +/// let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); +/// +/// let signer = keypair_from_path( +/// &clap_matches, +/// &keypair_str, +/// "keypair", +/// false, +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn keypair_from_path( + matches: &ArgMatches, + path: &str, + keypair_name: &str, + confirm_pubkey: bool, +) -> Result> { + let SignerSource { + kind, + derivation_path, + legacy, + } = parse_signer_source(path)?; + match kind { + SignerSourceKind::Prompt => { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + Ok(keypair_from_seed_phrase( + keypair_name, + skip_validation, + confirm_pubkey, + derivation_path, + legacy, + )?) + } + SignerSourceKind::Filepath(path) => match read_keypair_file(&path) { + Err(e) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "could not read keypair file \"{}\". \ + Run \"solana-keygen new\" to create a keypair file: {}", + path, e + ), + ) + .into()), + Ok(file) => Ok(file), + }, + SignerSourceKind::Stdin => { + let mut stdin = std::io::stdin(); + Ok(read_keypair(&mut stdin)?) + } + _ => Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "signer of type `{:?}` does not support Keypair output", + kind + ), + ) + .into()), + } +} + +/// Reads user input from stdin to retrieve a seed phrase and passphrase for keypair derivation. +/// +/// Optionally skips validation of seed phrase. Optionally confirms recovered +/// public key. +pub fn keypair_from_seed_phrase( + keypair_name: &str, + skip_validation: bool, + confirm_pubkey: bool, + derivation_path: Option, + legacy: bool, +) -> Result> { + let seed_phrase = prompt_password(&format!("[{}] seed phrase: ", keypair_name))?; + let seed_phrase = seed_phrase.trim(); + let passphrase_prompt = format!( + "[{}] If this seed phrase has an associated passphrase, enter it now. Otherwise, press ENTER to continue: ", + keypair_name, + ); + + let keypair = if skip_validation { + let passphrase = prompt_passphrase(&passphrase_prompt)?; + if legacy { + keypair_from_seed_phrase_and_passphrase(seed_phrase, &passphrase)? + } else { + let seed = generate_seed_from_seed_phrase_and_passphrase(seed_phrase, &passphrase); + keypair_from_seed_and_derivation_path(&seed, derivation_path)? + } + } else { + let sanitized = sanitize_seed_phrase(seed_phrase); + let parse_language_fn = || { + for language in &[ + Language::English, + Language::ChineseSimplified, + Language::ChineseTraditional, + Language::Japanese, + Language::Spanish, + Language::Korean, + Language::French, + Language::Italian, + ] { + if let Ok(mnemonic) = Mnemonic::from_phrase(&sanitized, *language) { + return Ok(mnemonic); + } + } + Err("Can't get mnemonic from seed phrases") + }; + let mnemonic = parse_language_fn()?; + let passphrase = prompt_passphrase(&passphrase_prompt)?; + let seed = Seed::new(&mnemonic, &passphrase); + if legacy { + keypair_from_seed(seed.as_bytes())? + } else { + keypair_from_seed_and_derivation_path(seed.as_bytes(), derivation_path)? + } + }; + + if confirm_pubkey { + let pubkey = keypair.pubkey(); + print!("Recovered pubkey `{:?}`. Continue? (y/n): ", pubkey); + let _ignored = stdout().flush(); + let mut input = String::new(); + stdin().read_line(&mut input).expect("Unexpected input"); + if input.to_lowercase().trim() != "y" { + println!("Exiting"); + exit(1); + } + } + + Ok(keypair) +} + +fn sanitize_seed_phrase(seed_phrase: &str) -> String { + seed_phrase + .split_whitespace() + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::offline::OfflineArgs, + clap::{Arg, Command}, + solana_remote_wallet::{locator::Manufacturer, remote_wallet::initialize_wallet_manager}, + solana_sdk::{signer::keypair::write_keypair_file, system_instruction}, + tempfile::{NamedTempFile, TempDir}, + }; + + #[test] + fn test_sanitize_seed_phrase() { + let seed_phrase = " Mary had\ta\u{2009}little \n\t lamb"; + assert_eq!( + "Mary had a little lamb".to_owned(), + sanitize_seed_phrase(seed_phrase) + ); + } + + #[test] + fn test_signer_info_signers_for_message() { + let source = Keypair::new(); + let fee_payer = Keypair::new(); + let nonsigner1 = Keypair::new(); + let nonsigner2 = Keypair::new(); + let recipient = Pubkey::new_unique(); + let message = Message::new( + &[system_instruction::transfer( + &source.pubkey(), + &recipient, + 42, + )], + Some(&fee_payer.pubkey()), + ); + let signers = vec![ + Box::new(fee_payer) as Box, + Box::new(source) as Box, + Box::new(nonsigner1) as Box, + Box::new(nonsigner2) as Box, + ]; + let signer_info = CliSignerInfo { signers }; + let msg_signers = signer_info.signers_for_message(&message); + let signer_pubkeys = msg_signers.iter().map(|s| s.pubkey()).collect::>(); + let expect = vec![ + signer_info.signers[0].pubkey(), + signer_info.signers[1].pubkey(), + ]; + assert_eq!(signer_pubkeys, expect); + } + + #[test] + fn test_parse_signer_source() { + assert!(matches!( + parse_signer_source(STDOUT_OUTFILE_TOKEN).unwrap(), + SignerSource { + kind: SignerSourceKind::Stdin, + derivation_path: None, + legacy: false, + } + )); + let stdin = "stdin:".to_string(); + assert!(matches!( + parse_signer_source(&stdin).unwrap(), + SignerSource { + kind: SignerSourceKind::Stdin, + derivation_path: None, + legacy: false, + } + )); + assert!(matches!( + parse_signer_source(ASK_KEYWORD).unwrap(), + SignerSource { + kind: SignerSourceKind::Prompt, + derivation_path: None, + legacy: true, + } + )); + let pubkey = Pubkey::new_unique(); + assert!( + matches!(parse_signer_source(&pubkey.to_string()).unwrap(), SignerSource { + kind: SignerSourceKind::Pubkey(p), + derivation_path: None, + legacy: false, + } + if p == pubkey) + ); + + // Set up absolute and relative path strs + let file0 = NamedTempFile::new().unwrap(); + let path = file0.path(); + assert!(path.is_absolute()); + let absolute_path_str = path.to_str().unwrap(); + + let file1 = NamedTempFile::new_in(std::env::current_dir().unwrap()).unwrap(); + let path = file1.path().file_name().unwrap().to_str().unwrap(); + let path = std::path::Path::new(path); + assert!(path.is_relative()); + let relative_path_str = path.to_str().unwrap(); + + assert!( + matches!(parse_signer_source(absolute_path_str).unwrap(), SignerSource { + kind: SignerSourceKind::Filepath(p), + derivation_path: None, + legacy: false, + } if p == absolute_path_str) + ); + assert!( + matches!(parse_signer_source(&relative_path_str).unwrap(), SignerSource { + kind: SignerSourceKind::Filepath(p), + derivation_path: None, + legacy: false, + } if p == relative_path_str) + ); + + let usb = "usb://ledger".to_string(); + let expected_locator = RemoteWalletLocator { + manufacturer: Manufacturer::Ledger, + pubkey: None, + }; + assert!(matches!(parse_signer_source(&usb).unwrap(), SignerSource { + kind: SignerSourceKind::Usb(u), + derivation_path: None, + legacy: false, + } if u == expected_locator)); + let usb = "usb://ledger?key=0/0".to_string(); + let expected_locator = RemoteWalletLocator { + manufacturer: Manufacturer::Ledger, + pubkey: None, + }; + let expected_derivation_path = Some(DerivationPath::new_bip44(Some(0), Some(0))); + assert!(matches!(parse_signer_source(&usb).unwrap(), SignerSource { + kind: SignerSourceKind::Usb(u), + derivation_path: d, + legacy: false, + } if u == expected_locator && d == expected_derivation_path)); + // Catchall into SignerSource::Filepath fails + let junk = "sometextthatisnotapubkeyorfile".to_string(); + assert!(Pubkey::from_str(&junk).is_err()); + assert!(matches!( + parse_signer_source(&junk), + Err(SignerSourceError::IoError(_)) + )); + + let prompt = "prompt:".to_string(); + assert!(matches!( + parse_signer_source(&prompt).unwrap(), + SignerSource { + kind: SignerSourceKind::Prompt, + derivation_path: None, + legacy: false, + } + )); + assert!( + matches!(parse_signer_source(&format!("file:{}", absolute_path_str)).unwrap(), SignerSource { + kind: SignerSourceKind::Filepath(p), + derivation_path: None, + legacy: false, + } if p == absolute_path_str) + ); + assert!( + matches!(parse_signer_source(&format!("file:{}", relative_path_str)).unwrap(), SignerSource { + kind: SignerSourceKind::Filepath(p), + derivation_path: None, + legacy: false, + } if p == relative_path_str) + ); + } + + #[test] + fn signer_from_path_with_file() -> Result<(), Box> { + let dir = TempDir::new()?; + let dir = dir.path(); + let keypair_path = dir.join("id.json"); + let keypair_path_str = keypair_path.to_str().expect("utf-8"); + + let keypair = Keypair::new(); + write_keypair_file(&keypair, &keypair_path)?; + + let args = vec!["program", keypair_path_str]; + + let clap_app = Command::new("my-program") + .arg( + Arg::new("keypair") + .required(true) + .help("The signing keypair"), + ) + .offline_args(); + + let clap_matches = clap_app.get_matches_from(args); + let keypair_str: String = clap_matches.value_of_t_or_exit("keypair"); + + let wallet_manager = initialize_wallet_manager()?; + + let signer = signer_from_path( + &clap_matches, + &keypair_str, + "signer", + &mut Some(wallet_manager), + )?; + + assert_eq!(keypair.pubkey(), signer.pubkey()); + + Ok(()) + } +} diff --git a/clap-v3-utils/src/lib.rs b/clap-v3-utils/src/lib.rs new file mode 100644 index 000000000..c6f7e8e2d --- /dev/null +++ b/clap-v3-utils/src/lib.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +pub struct ArgConstant<'a> { + pub long: &'a str, + pub name: &'a str, + pub help: &'a str, +} + +/// Error type for forwarding Errors out of `main()` of a `clap` app +/// and still using the `Display` formatter +#[derive(Error)] +#[error("{0}")] +pub struct DisplayError(Box); +impl DisplayError { + pub fn new_as_boxed(inner: Box) -> Box { + DisplayError(inner).into() + } +} + +impl std::fmt::Debug for DisplayError { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.0) + } +} + +pub mod fee_payer; +pub mod input_parsers; +pub mod input_validators; +pub mod keypair; +pub mod memo; +pub mod nonce; +pub mod offline; diff --git a/clap-v3-utils/src/memo.rs b/clap-v3-utils/src/memo.rs new file mode 100644 index 000000000..8e29bac33 --- /dev/null +++ b/clap-v3-utils/src/memo.rs @@ -0,0 +1,15 @@ +use {crate::ArgConstant, clap::Arg}; + +pub const MEMO_ARG: ArgConstant<'static> = ArgConstant { + name: "memo", + long: "--with-memo", + help: "Specify a memo string to include in the transaction.", +}; + +pub fn memo_arg<'a>() -> Arg<'a> { + Arg::new(MEMO_ARG.name) + .long(MEMO_ARG.long) + .takes_value(true) + .value_name("MEMO") + .help(MEMO_ARG.help) +} diff --git a/clap-v3-utils/src/nonce.rs b/clap-v3-utils/src/nonce.rs new file mode 100644 index 000000000..8088edf10 --- /dev/null +++ b/clap-v3-utils/src/nonce.rs @@ -0,0 +1,52 @@ +use { + crate::{input_validators::*, offline::BLOCKHASH_ARG, ArgConstant}, + clap::{Arg, Command}, +}; + +pub const NONCE_ARG: ArgConstant<'static> = ArgConstant { + name: "nonce", + long: "nonce", + help: "Provide the nonce account to use when creating a nonced \n\ + transaction. Nonced transactions are useful when a transaction \n\ + requires a lengthy signing process. Learn more about nonced \n\ + transactions at https://docs.solana.com/offline-signing/durable-nonce", +}; + +pub const NONCE_AUTHORITY_ARG: ArgConstant<'static> = ArgConstant { + name: "nonce_authority", + long: "nonce-authority", + help: "Provide the nonce authority keypair to use when signing a nonced transaction", +}; + +fn nonce_arg<'a>() -> Arg<'a> { + Arg::new(NONCE_ARG.name) + .long(NONCE_ARG.long) + .takes_value(true) + .value_name("PUBKEY") + .requires(BLOCKHASH_ARG.name) + .validator(|s| is_valid_pubkey(s)) + .help(NONCE_ARG.help) +} + +pub fn nonce_authority_arg<'a>() -> Arg<'a> { + Arg::new(NONCE_AUTHORITY_ARG.name) + .long(NONCE_AUTHORITY_ARG.long) + .takes_value(true) + .value_name("KEYPAIR") + .validator(|s| is_valid_signer(s)) + .help(NONCE_AUTHORITY_ARG.help) +} + +pub trait NonceArgs { + fn nonce_args(self, global: bool) -> Self; +} + +impl NonceArgs for Command<'_> { + fn nonce_args(self, global: bool) -> Self { + self.arg(nonce_arg().global(global)).arg( + nonce_authority_arg() + .requires(NONCE_ARG.name) + .global(global), + ) + } +} diff --git a/clap-v3-utils/src/offline.rs b/clap-v3-utils/src/offline.rs new file mode 100644 index 000000000..83c951dff --- /dev/null +++ b/clap-v3-utils/src/offline.rs @@ -0,0 +1,99 @@ +use { + crate::{input_validators::*, ArgConstant}, + clap::{Arg, Command}, +}; + +pub const BLOCKHASH_ARG: ArgConstant<'static> = ArgConstant { + name: "blockhash", + long: "blockhash", + help: "Use the supplied blockhash", +}; + +pub const SIGN_ONLY_ARG: ArgConstant<'static> = ArgConstant { + name: "sign_only", + long: "sign-only", + help: "Sign the transaction offline", +}; + +pub const SIGNER_ARG: ArgConstant<'static> = ArgConstant { + name: "signer", + long: "signer", + help: "Provide a public-key/signature pair for the transaction", +}; + +pub const DUMP_TRANSACTION_MESSAGE: ArgConstant<'static> = ArgConstant { + name: "dump_transaction_message", + long: "dump-transaction-message", + help: "Display the base64 encoded binary transaction message in sign-only mode", +}; + +pub fn blockhash_arg<'a>() -> Arg<'a> { + Arg::new(BLOCKHASH_ARG.name) + .long(BLOCKHASH_ARG.long) + .takes_value(true) + .value_name("BLOCKHASH") + .validator(|s| is_hash(s)) + .help(BLOCKHASH_ARG.help) +} + +pub fn sign_only_arg<'a>() -> Arg<'a> { + Arg::new(SIGN_ONLY_ARG.name) + .long(SIGN_ONLY_ARG.long) + .takes_value(false) + .requires(BLOCKHASH_ARG.name) + .help(SIGN_ONLY_ARG.help) +} + +fn signer_arg<'a>() -> Arg<'a> { + Arg::new(SIGNER_ARG.name) + .long(SIGNER_ARG.long) + .takes_value(true) + .value_name("PUBKEY=SIGNATURE") + .validator(|s| is_pubkey_sig(s)) + .requires(BLOCKHASH_ARG.name) + .multiple_occurrences(true) + .multiple_values(true) + .help(SIGNER_ARG.help) +} + +pub fn dump_transaction_message<'a>() -> Arg<'a> { + Arg::new(DUMP_TRANSACTION_MESSAGE.name) + .long(DUMP_TRANSACTION_MESSAGE.long) + .takes_value(false) + .requires(SIGN_ONLY_ARG.name) + .help(DUMP_TRANSACTION_MESSAGE.help) +} + +pub trait ArgsConfig { + fn blockhash_arg<'a>(&self, arg: Arg<'a>) -> Arg<'a> { + arg + } + fn sign_only_arg<'a>(&self, arg: Arg<'a>) -> Arg<'a> { + arg + } + fn signer_arg<'a>(&self, arg: Arg<'a>) -> Arg<'a> { + arg + } + fn dump_transaction_message_arg<'a>(&self, arg: Arg<'a>) -> Arg<'a> { + arg + } +} + +pub trait OfflineArgs { + fn offline_args(self) -> Self; + fn offline_args_config(self, config: &dyn ArgsConfig) -> Self; +} + +impl OfflineArgs for Command<'_> { + fn offline_args_config(self, config: &dyn ArgsConfig) -> Self { + self.arg(config.blockhash_arg(blockhash_arg())) + .arg(config.sign_only_arg(sign_only_arg())) + .arg(config.signer_arg(signer_arg())) + .arg(config.dump_transaction_message_arg(dump_transaction_message())) + } + fn offline_args(self) -> Self { + struct NullArgsConfig {} + impl ArgsConfig for NullArgsConfig {} + self.offline_args_config(&NullArgsConfig {}) + } +}