diff --git a/clap-utils/src/input_parsers.rs b/clap-utils/src/input_parsers.rs index fa4b78e007..503ce73fe8 100644 --- a/clap-utils/src/input_parsers.rs +++ b/clap-utils/src/input_parsers.rs @@ -3,8 +3,9 @@ use clap::ArgMatches; use solana_sdk::{ native_token::sol_to_lamports, pubkey::Pubkey, - signature::{read_keypair_file, Keypair, KeypairUtil}, + signature::{read_keypair_file, Keypair, KeypairUtil, Signature}, }; +use std::str::FromStr; // Return parsed values from matches at `name` pub fn values_of(matches: &ArgMatches<'_>, name: &str) -> Option> @@ -50,6 +51,20 @@ pub fn pubkey_of(matches: &ArgMatches<'_>, name: &str) -> Option { value_of(matches, name).or_else(|| keypair_of(matches, name).map(|keypair| keypair.pubkey())) } +// 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() + }) +} + pub fn amount_of(matches: &ArgMatches<'_>, name: &str, unit: &str) -> Option { if matches.value_of(unit) == Some("lamports") { value_of(matches, name) @@ -172,4 +187,25 @@ mod tests { fs::remove_file(&outfile).unwrap(); } + + #[test] + fn test_pubkeys_sigs_of() { + let key1 = Pubkey::new_rand(); + let key2 = 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)]) + ); + } } diff --git a/clap-utils/src/input_validators.rs b/clap-utils/src/input_validators.rs index 6ec3cc1023..c4b8ae96d8 100644 --- a/clap-utils/src/input_validators.rs +++ b/clap-utils/src/input_validators.rs @@ -1,6 +1,8 @@ use crate::keypair::ASK_KEYWORD; +use solana_sdk::hash::Hash; use solana_sdk::pubkey::Pubkey; -use solana_sdk::signature::read_keypair_file; +use solana_sdk::signature::{read_keypair_file, Signature}; +use std::str::FromStr; // Return an error if a pubkey cannot be parsed. pub fn is_pubkey(string: String) -> Result<(), String> { @@ -10,6 +12,14 @@ pub fn is_pubkey(string: String) -> Result<(), String> { } } +// Return an error if a hash cannot be parsed. +pub fn is_hash(string: String) -> Result<(), String> { + match string.parse::() { + Ok(_) => Ok(()), + Err(err) => Err(format!("{:?}", err)), + } +} + // Return an error if a keypair file cannot be parsed. pub fn is_keypair(string: String) -> Result<(), String> { read_keypair_file(&string) @@ -32,6 +42,28 @@ pub fn is_pubkey_or_keypair(string: String) -> Result<(), String> { is_pubkey(string.clone()).or_else(|_| is_keypair(string)) } +// Return an error if string cannot be parsed as pubkey=signature string +pub fn is_pubkey_sig(string: String) -> Result<(), String> { + let mut signer = string.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: String) -> Result<(), String> { match url::Url::parse(&string) { diff --git a/cli/src/cli.rs b/cli/src/cli.rs index e72df49a02..47c10f371f 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -1,5 +1,10 @@ use crate::{ - cluster_query::*, display::println_name_value, stake::*, storage::*, validator_info::*, vote::*, + cluster_query::*, + display::{println_name_value, println_signers}, + stake::*, + storage::*, + validator_info::*, + vote::*, }; use chrono::prelude::*; use clap::{App, AppSettings, Arg, ArgMatches, SubCommand}; @@ -105,8 +110,20 @@ pub enum CliCommand { lockup: Lockup, lamports: u64, }, - DeactivateStake(Pubkey), - DelegateStake(Pubkey, Pubkey, bool), + DeactivateStake { + stake_account_pubkey: Pubkey, + sign_only: bool, + signers: Option>, + blockhash: Option, + }, + DelegateStake { + stake_account_pubkey: Pubkey, + vote_account_pubkey: Pubkey, + force: bool, + sign_only: bool, + signers: Option>, + blockhash: Option, + }, RedeemVoteCredits(Pubkey, Pubkey), ShowStakeHistory { use_lamports_unit: bool, @@ -174,6 +191,9 @@ pub enum CliCommand { timestamp_pubkey: Option, witnesses: Option>, cancelable: bool, + sign_only: bool, + signers: Option>, + blockhash: Option, }, ShowAccount { pubkey: Pubkey, @@ -413,6 +433,9 @@ pub fn parse_command(matches: &ArgMatches<'_>) -> Result) -> Result { @@ -522,6 +548,48 @@ pub fn check_unique_pubkeys( } } +pub fn get_blockhash_fee_calculator( + rpc_client: &RpcClient, + sign_only: bool, + blockhash: Option, +) -> Result<(Hash, FeeCalculator), Box> { + Ok(if let Some(blockhash) = blockhash { + if sign_only { + (blockhash, FeeCalculator::default()) + } else { + (blockhash, rpc_client.get_recent_blockhash()?.1) + } + } else { + rpc_client.get_recent_blockhash()? + }) +} + +pub fn return_signers(tx: &Transaction) -> ProcessResult { + println_signers(tx); + let signers: Vec<_> = tx + .signatures + .iter() + .zip(tx.message.account_keys.clone()) + .map(|(signature, pubkey)| format!("{}={}", pubkey, signature)) + .collect(); + + Ok(json!({ + "blockhash": tx.message.recent_blockhash.to_string(), + "signers": &signers, + }) + .to_string()) +} + +pub fn replace_signatures(tx: &mut Transaction, signers: &[(Pubkey, Signature)]) -> ProcessResult { + tx.replace_signatures(signers).map_err(|_| { + CliError::BadParameter( + "Transaction construction failed, incorrect signature or public key provided" + .to_string(), + ) + })?; + Ok("".to_string()) +} + fn process_airdrop( rpc_client: &RpcClient, config: &CliConfig, @@ -694,6 +762,7 @@ fn process_deploy( .to_string()) } +#[allow(clippy::too_many_arguments)] fn process_pay( rpc_client: &RpcClient, config: &CliConfig, @@ -703,12 +772,17 @@ fn process_pay( timestamp_pubkey: Option, witnesses: &Option>, cancelable: bool, + sign_only: bool, + signers: &Option>, + blockhash: Option, ) -> ProcessResult { check_unique_pubkeys( (&config.keypair.pubkey(), "cli keypair".to_string()), (to, "to".to_string()), )?; - let (blockhash, fee_calculator) = rpc_client.get_recent_blockhash()?; + + let (blockhash, fee_calculator) = + get_blockhash_fee_calculator(rpc_client, sign_only, blockhash)?; let cancelable = if cancelable { Some(config.keypair.pubkey()) @@ -718,9 +792,17 @@ fn process_pay( if timestamp == None && *witnesses == None { let mut tx = system_transaction::transfer(&config.keypair, to, lamports, blockhash); - check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; - let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); - log_instruction_custom_error::(result) + if let Some(signers) = signers { + replace_signatures(&mut tx, &signers)?; + } + + if sign_only { + return_signers(&tx) + } else { + check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; + let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); + log_instruction_custom_error::(result) + } } else if *witnesses == None { let dt = timestamp.unwrap(); let dt_pubkey = match timestamp_pubkey { @@ -745,19 +827,24 @@ fn process_pay( ixs, blockhash, ); - check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; - let result = - rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair, &contract_state]); - let signature_str = log_instruction_custom_error::(result)?; + if let Some(signers) = signers { + replace_signatures(&mut tx, &signers)?; + } + if sign_only { + return_signers(&tx) + } else { + check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; + let result = rpc_client + .send_and_confirm_transaction(&mut tx, &[&config.keypair, &contract_state]); + let signature_str = log_instruction_custom_error::(result)?; - Ok(json!({ - "signature": signature_str, - "processId": format!("{}", contract_state.pubkey()), - }) - .to_string()) + Ok(json!({ + "signature": signature_str, + "processId": format!("{}", contract_state.pubkey()), + }) + .to_string()) + } } else if timestamp == None { - let (blockhash, _fee_calculator) = rpc_client.get_recent_blockhash()?; - let witness = if let Some(ref witness_vec) = *witnesses { witness_vec[0] } else { @@ -783,16 +870,23 @@ fn process_pay( ixs, blockhash, ); - let result = - rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair, &contract_state]); - check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; - let signature_str = log_instruction_custom_error::(result)?; + if let Some(signers) = signers { + replace_signatures(&mut tx, &signers)?; + } + if sign_only { + return_signers(&tx) + } else { + let result = rpc_client + .send_and_confirm_transaction(&mut tx, &[&config.keypair, &contract_state]); + check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; + let signature_str = log_instruction_custom_error::(result)?; - Ok(json!({ - "signature": signature_str, - "processId": format!("{}", contract_state.pubkey()), - }) - .to_string()) + Ok(json!({ + "signature": signature_str, + "processId": format!("{}", contract_state.pubkey()), + }) + .to_string()) + } } else { Ok("Combo transactions not yet handled".to_string()) } @@ -926,18 +1020,36 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { *lamports, ), // Deactivate stake account - CliCommand::DeactivateStake(stake_account_pubkey) => { - process_deactivate_stake_account(&rpc_client, config, &stake_account_pubkey) - } - CliCommand::DelegateStake(stake_account_pubkey, vote_account_pubkey, force) => { - process_delegate_stake( - &rpc_client, - config, - &stake_account_pubkey, - &vote_account_pubkey, - *force, - ) - } + CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only, + ref signers, + blockhash, + } => process_deactivate_stake_account( + &rpc_client, + config, + &stake_account_pubkey, + *sign_only, + signers, + *blockhash, + ), + CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force, + sign_only, + ref signers, + blockhash, + } => process_delegate_stake( + &rpc_client, + config, + &stake_account_pubkey, + &vote_account_pubkey, + *force, + *sign_only, + signers, + *blockhash, + ), CliCommand::RedeemVoteCredits(stake_account_pubkey, vote_account_pubkey) => { process_redeem_vote_credits( &rpc_client, @@ -1118,6 +1230,9 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { timestamp_pubkey, ref witnesses, cancelable, + sign_only, + ref signers, + blockhash, } => process_pay( &rpc_client, config, @@ -1127,6 +1242,9 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { *timestamp_pubkey, witnesses, *cancelable, + *sign_only, + signers, + *blockhash, ), CliCommand::ShowAccount { pubkey, @@ -1410,6 +1528,29 @@ pub fn app<'ab, 'v>(name: &str, about: &'ab str, version: &'v str) -> App<'ab, ' Arg::with_name("cancelable") .long("cancelable") .takes_value(false), + ) + .arg( + Arg::with_name("sign_only") + .long("sign-only") + .takes_value(false) + .help("Sign the transaction offline"), + ) + .arg( + Arg::with_name("signer") + .long("signer") + .value_name("PUBKEY=BASE58_SIG") + .takes_value(true) + .validator(is_pubkey_sig) + .multiple(true) + .help("Provide a public-key/signature pair for the transaction"), + ) + .arg( + Arg::with_name("blockhash") + .long("blockhash") + .value_name("BLOCKHASH") + .takes_value(true) + .validator(is_hash) + .help("Use the supplied blockhash"), ), ) .subcommand( @@ -1667,6 +1808,9 @@ mod tests { timestamp_pubkey: None, witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }, require_keypair: true } @@ -1694,6 +1838,9 @@ mod tests { timestamp_pubkey: None, witnesses: Some(vec![witness0, witness1]), cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }, require_keypair: true } @@ -1717,6 +1864,9 @@ mod tests { timestamp_pubkey: None, witnesses: Some(vec![witness0]), cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }, require_keypair: true } @@ -1744,6 +1894,130 @@ mod tests { timestamp_pubkey: Some(witness0), witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, + }, + require_keypair: true + } + ); + + // Test Pay Subcommand w/ sign-only + let test_pay = test_commands.clone().get_matches_from(vec![ + "test", + "pay", + &pubkey_string, + "50", + "lamports", + "--sign-only", + ]); + assert_eq!( + parse_command(&test_pay).unwrap(), + CliCommandInfo { + command: CliCommand::Pay { + lamports: 50, + to: pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: true, + signers: None, + blockhash: None, + }, + require_keypair: false + } + ); + + // Test Pay Subcommand w/ signer + let key1 = Pubkey::new_rand(); + let sig1 = Keypair::new().sign_message(&[0u8]); + let signer1 = format!("{}={}", key1, sig1); + let test_pay = test_commands.clone().get_matches_from(vec![ + "test", + "pay", + &pubkey_string, + "50", + "lamports", + "--signer", + &signer1, + ]); + assert_eq!( + parse_command(&test_pay).unwrap(), + CliCommandInfo { + command: CliCommand::Pay { + lamports: 50, + to: pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: false, + signers: Some(vec![(key1, sig1)]), + blockhash: None, + }, + require_keypair: true + } + ); + + // Test Pay Subcommand w/ signers + let key2 = Pubkey::new_rand(); + let sig2 = Keypair::new().sign_message(&[1u8]); + let signer2 = format!("{}={}", key2, sig2); + let test_pay = test_commands.clone().get_matches_from(vec![ + "test", + "pay", + &pubkey_string, + "50", + "lamports", + "--signer", + &signer1, + "--signer", + &signer2, + ]); + assert_eq!( + parse_command(&test_pay).unwrap(), + CliCommandInfo { + command: CliCommand::Pay { + lamports: 50, + to: pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: false, + signers: Some(vec![(key1, sig1), (key2, sig2)]), + blockhash: None, + }, + require_keypair: true + } + ); + + // Test Pay Subcommand w/ Blockhash + let blockhash = Hash::default(); + let blockhash_string = format!("{}", blockhash); + let test_pay = test_commands.clone().get_matches_from(vec![ + "test", + "pay", + &pubkey_string, + "50", + "lamports", + "--blockhash", + &blockhash_string, + ]); + assert_eq!( + parse_command(&test_pay).unwrap(), + CliCommandInfo { + command: CliCommand::Pay { + lamports: 50, + to: pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: false, + signers: None, + blockhash: Some(blockhash), }, require_keypair: true } @@ -1788,6 +2062,9 @@ mod tests { timestamp_pubkey: Some(witness0), witnesses: Some(vec![witness0, witness1]), cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }, require_keypair: true } @@ -1894,7 +2171,12 @@ mod tests { assert_eq!(signature.unwrap(), SIGNATURE.to_string()); let stake_pubkey = Pubkey::new_rand(); - config.command = CliCommand::DeactivateStake(stake_pubkey); + config.command = CliCommand::DeactivateStake { + stake_account_pubkey: stake_pubkey, + sign_only: false, + signers: None, + blockhash: None, + }; let signature = process_command(&config); assert_eq!(signature.unwrap(), SIGNATURE.to_string()); @@ -1915,6 +2197,9 @@ mod tests { timestamp_pubkey: None, witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; let signature = process_command(&config); assert_eq!(signature.unwrap(), SIGNATURE.to_string()); @@ -1928,6 +2213,9 @@ mod tests { timestamp_pubkey: Some(config.keypair.pubkey()), witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; let result = process_command(&config); let json: Value = serde_json::from_str(&result.unwrap()).unwrap(); @@ -1949,6 +2237,9 @@ mod tests { timestamp_pubkey: None, witnesses: Some(vec![witness]), cancelable: true, + sign_only: false, + signers: None, + blockhash: None, }; let result = process_command(&config); let json: Value = serde_json::from_str(&result.unwrap()).unwrap(); @@ -2056,6 +2347,9 @@ mod tests { timestamp_pubkey: None, witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; assert!(process_command(&config).is_err()); @@ -2066,6 +2360,9 @@ mod tests { timestamp_pubkey: Some(config.keypair.pubkey()), witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; assert!(process_command(&config).is_err()); @@ -2076,6 +2373,9 @@ mod tests { timestamp_pubkey: None, witnesses: Some(vec![witness]), cancelable: true, + sign_only: false, + signers: None, + blockhash: None, }; assert!(process_command(&config).is_err()); diff --git a/cli/src/display.rs b/cli/src/display.rs index ec9723806c..b31d07601c 100644 --- a/cli/src/display.rs +++ b/cli/src/display.rs @@ -1,4 +1,5 @@ use console::style; +use solana_sdk::transaction::Transaction; // Pretty print a "name value" pub fn println_name_value(name: &str, value: &str) { @@ -22,3 +23,14 @@ pub fn println_name_value_or(name: &str, value: &str, default_value: &str) { println!("{} {}", style(name).bold(), style(value)); }; } + +pub fn println_signers(tx: &Transaction) { + println!(); + println!("Blockhash: {}", tx.message.recent_blockhash); + println!("Signers (Pubkey=Signature):"); + tx.signatures + .iter() + .zip(tx.message.account_keys.clone()) + .for_each(|(signature, pubkey)| println!(" {:?}={:?}", pubkey, signature)); + println!(); +} diff --git a/cli/src/stake.rs b/cli/src/stake.rs index b427551731..c5786c57ea 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -1,14 +1,16 @@ use crate::cli::{ build_balance_message, check_account_for_fee, check_unique_pubkeys, - log_instruction_custom_error, CliCommand, CliCommandInfo, CliConfig, CliError, ProcessResult, + get_blockhash_fee_calculator, log_instruction_custom_error, replace_signatures, return_signers, + CliCommand, CliCommandInfo, CliConfig, CliError, ProcessResult, }; use clap::{App, Arg, ArgMatches, SubCommand}; use console::style; use solana_clap_utils::{input_parsers::*, input_validators::*}; use solana_client::rpc_client::RpcClient; -use solana_sdk::signature::Keypair; +use solana_sdk::signature::{Keypair, Signature}; use solana_sdk::{ account_utils::State, + hash::Hash, pubkey::Pubkey, signature::KeypairUtil, system_instruction::SystemError, @@ -120,6 +122,29 @@ impl StakeSubCommands for App<'_, '_> { .validator(is_pubkey_or_keypair) .help("The vote account to which the stake will be delegated") ) + .arg( + Arg::with_name("sign_only") + .long("sign-only") + .takes_value(false) + .help("Sign the transaction offline"), + ) + .arg( + Arg::with_name("signer") + .long("signer") + .value_name("PUBKEY=BASE58_SIG") + .takes_value(true) + .validator(is_pubkey_sig) + .multiple(true) + .help("Provide a public-key/signature pair for the transaction"), + ) + .arg( + Arg::with_name("blockhash") + .long("blockhash") + .value_name("BLOCKHASH") + .takes_value(true) + .validator(is_hash) + .help("Use the supplied blockhash"), + ), ) .subcommand( SubCommand::with_name("stake-authorize-staker") @@ -176,6 +201,29 @@ impl StakeSubCommands for App<'_, '_> { .required(true) .help("Stake account to be deactivated.") ) + .arg( + Arg::with_name("sign_only") + .long("sign-only") + .takes_value(false) + .help("Sign the transaction offline"), + ) + .arg( + Arg::with_name("signer") + .long("signer") + .value_name("PUBKEY=BASE58_SIG") + .takes_value(true) + .validator(is_pubkey_sig) + .multiple(true) + .help("Provide a public-key/signature pair for the transaction"), + ) + .arg( + Arg::with_name("blockhash") + .long("blockhash") + .value_name("BLOCKHASH") + .takes_value(true) + .validator(is_hash) + .help("Use the supplied blockhash"), + ), ) .subcommand( SubCommand::with_name("withdraw-stake") @@ -293,10 +341,20 @@ pub fn parse_stake_delegate_stake(matches: &ArgMatches<'_>) -> Result) -> Result) -> Result { let stake_account_pubkey = pubkey_of(matches, "stake_account_pubkey").unwrap(); + let sign_only = matches.is_present("sign_only"); + let signers = pubkeys_sigs_of(&matches, "signer"); + let blockhash = value_of(matches, "blockhash"); Ok(CliCommandInfo { - command: CliCommand::DeactivateStake(stake_account_pubkey), - require_keypair: true, + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only, + signers, + blockhash, + }, + require_keypair: !sign_only, }) } @@ -463,8 +529,12 @@ pub fn process_deactivate_stake_account( rpc_client: &RpcClient, config: &CliConfig, stake_account_pubkey: &Pubkey, + sign_only: bool, + signers: &Option>, + blockhash: Option, ) -> ProcessResult { - let (recent_blockhash, fee_calculator) = rpc_client.get_recent_blockhash()?; + let (recent_blockhash, fee_calculator) = + get_blockhash_fee_calculator(rpc_client, sign_only, blockhash)?; let ixs = vec![stake_instruction::deactivate_stake( stake_account_pubkey, &config.keypair.pubkey(), @@ -475,9 +545,16 @@ pub fn process_deactivate_stake_account( &[&config.keypair], recent_blockhash, ); - check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; - let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); - log_instruction_custom_error::(result) + if let Some(signers) = signers { + replace_signatures(&mut tx, &signers)?; + } + if sign_only { + return_signers(&tx) + } else { + check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; + let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); + log_instruction_custom_error::(result) + } } pub fn process_withdraw_stake( @@ -644,6 +721,9 @@ pub fn process_delegate_stake( stake_account_pubkey: &Pubkey, vote_account_pubkey: &Pubkey, force: bool, + sign_only: bool, + signers: &Option>, + blockhash: Option, ) -> ProcessResult { check_unique_pubkeys( (&config.keypair.pubkey(), "cli keypair".to_string()), @@ -690,7 +770,8 @@ pub fn process_delegate_stake( } } - let (recent_blockhash, fee_calculator) = rpc_client.get_recent_blockhash()?; + let (recent_blockhash, fee_calculator) = + get_blockhash_fee_calculator(rpc_client, sign_only, blockhash)?; let ixs = vec![stake_instruction::delegate_stake( stake_account_pubkey, @@ -704,9 +785,16 @@ pub fn process_delegate_stake( &[&config.keypair], recent_blockhash, ); - check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; - let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); - log_instruction_custom_error::(result) + if let Some(signers) = signers { + replace_signatures(&mut tx, &signers)?; + } + if sign_only { + return_signers(&tx) + } else { + check_account_for_fee(rpc_client, config, &fee_calculator, &tx.message)?; + let result = rpc_client.send_and_confirm_transaction(&mut tx, &[&config.keypair]); + log_instruction_custom_error::(result) + } } #[cfg(test)] @@ -831,18 +919,25 @@ mod tests { ); // Test DelegateStake Subcommand - let stake_pubkey = Pubkey::new_rand(); - let stake_pubkey_string = stake_pubkey.to_string(); + let vote_account_pubkey = Pubkey::new_rand(); + let vote_account_string = vote_account_pubkey.to_string(); let test_delegate_stake = test_commands.clone().get_matches_from(vec![ "test", "delegate-stake", - &stake_pubkey_string, &stake_account_string, + &vote_account_string, ]); assert_eq!( parse_command(&test_delegate_stake).unwrap(), CliCommandInfo { - command: CliCommand::DelegateStake(stake_pubkey, stake_account_pubkey, false), + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: false, + sign_only: false, + signers: None, + blockhash: None + }, require_keypair: true } ); @@ -851,13 +946,124 @@ mod tests { "test", "delegate-stake", "--force", - &stake_pubkey_string, &stake_account_string, + &vote_account_string, ]); assert_eq!( parse_command(&test_delegate_stake).unwrap(), CliCommandInfo { - command: CliCommand::DelegateStake(stake_pubkey, stake_account_pubkey, true), + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: true, + sign_only: false, + signers: None, + blockhash: None + }, + require_keypair: true + } + ); + + // Test Delegate Subcommand w/ Blockhash + let blockhash = Hash::default(); + let blockhash_string = format!("{}", blockhash); + let test_delegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "delegate-stake", + &stake_account_string, + &vote_account_string, + "--blockhash", + &blockhash_string, + ]); + assert_eq!( + parse_command(&test_delegate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: false, + sign_only: false, + signers: None, + blockhash: Some(blockhash) + }, + require_keypair: true + } + ); + + let test_delegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "delegate-stake", + &stake_account_string, + &vote_account_string, + "--sign-only", + ]); + assert_eq!( + parse_command(&test_delegate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: false, + sign_only: true, + signers: None, + blockhash: None + }, + require_keypair: false + } + ); + + // Test Delegate Subcommand w/ signer + let key1 = Pubkey::new_rand(); + let sig1 = Keypair::new().sign_message(&[0u8]); + let signer1 = format!("{}={}", key1, sig1); + let test_delegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "delegate-stake", + &stake_account_string, + &vote_account_string, + "--signer", + &signer1, + ]); + assert_eq!( + parse_command(&test_delegate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: false, + sign_only: false, + signers: Some(vec![(key1, sig1)]), + blockhash: None + }, + require_keypair: true + } + ); + + // Test Delegate Subcommand w/ signers + let key2 = Pubkey::new_rand(); + let sig2 = Keypair::new().sign_message(&[0u8]); + let signer2 = format!("{}={}", key2, sig2); + let test_delegate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "delegate-stake", + &stake_account_string, + &vote_account_string, + "--signer", + &signer1, + "--signer", + &signer2, + ]); + assert_eq!( + parse_command(&test_delegate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DelegateStake { + stake_account_pubkey, + vote_account_pubkey, + force: false, + sign_only: false, + signers: Some(vec![(key1, sig1), (key2, sig2)]), + blockhash: None + }, require_keypair: true } ); @@ -866,7 +1072,7 @@ mod tests { let test_withdraw_stake = test_commands.clone().get_matches_from(vec![ "test", "withdraw-stake", - &stake_pubkey_string, + &stake_account_string, &stake_account_string, "42", "lamports", @@ -875,7 +1081,7 @@ mod tests { assert_eq!( parse_command(&test_withdraw_stake).unwrap(), CliCommandInfo { - command: CliCommand::WithdrawStake(stake_pubkey, stake_account_pubkey, 42), + command: CliCommand::WithdrawStake(stake_account_pubkey, stake_account_pubkey, 42), require_keypair: true } ); @@ -884,12 +1090,109 @@ mod tests { let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ "test", "deactivate-stake", - &stake_pubkey_string, + &stake_account_string, ]); assert_eq!( parse_command(&test_deactivate_stake).unwrap(), CliCommandInfo { - command: CliCommand::DeactivateStake(stake_pubkey), + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only: false, + signers: None, + blockhash: None + }, + require_keypair: true + } + ); + + // Test Deactivate Subcommand w/ Blockhash + let blockhash = Hash::default(); + let blockhash_string = format!("{}", blockhash); + let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "deactivate-stake", + &stake_account_string, + "--blockhash", + &blockhash_string, + ]); + assert_eq!( + parse_command(&test_deactivate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only: false, + signers: None, + blockhash: Some(blockhash) + }, + require_keypair: true + } + ); + + let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "deactivate-stake", + &stake_account_string, + "--sign-only", + ]); + assert_eq!( + parse_command(&test_deactivate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only: true, + signers: None, + blockhash: None + }, + require_keypair: false + } + ); + + // Test Deactivate Subcommand w/ signers + let key1 = Pubkey::new_rand(); + let sig1 = Keypair::new().sign_message(&[0u8]); + let signer1 = format!("{}={}", key1, sig1); + let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "deactivate-stake", + &stake_account_string, + "--signer", + &signer1, + ]); + assert_eq!( + parse_command(&test_deactivate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only: false, + signers: Some(vec![(key1, sig1)]), + blockhash: None + }, + require_keypair: true + } + ); + + // Test Deactivate Subcommand w/ signers + let key2 = Pubkey::new_rand(); + let sig2 = Keypair::new().sign_message(&[0u8]); + let signer2 = format!("{}={}", key2, sig2); + let test_deactivate_stake = test_commands.clone().get_matches_from(vec![ + "test", + "deactivate-stake", + &stake_account_string, + "--signer", + &signer1, + "--signer", + &signer2, + ]); + assert_eq!( + parse_command(&test_deactivate_stake).unwrap(), + CliCommandInfo { + command: CliCommand::DeactivateStake { + stake_account_pubkey, + sign_only: false, + signers: Some(vec![(key1, sig1), (key2, sig2)]), + blockhash: None + }, require_keypair: true } ); diff --git a/cli/tests/pay.rs b/cli/tests/pay.rs index e53dbeb823..3e44d85d98 100644 --- a/cli/tests/pay.rs +++ b/cli/tests/pay.rs @@ -3,9 +3,9 @@ use serde_json::Value; use solana_cli::cli::{process_command, request_and_confirm_airdrop, CliCommand, CliConfig}; use solana_client::rpc_client::RpcClient; use solana_drone::drone::run_local_drone; -use solana_sdk::pubkey::Pubkey; -use solana_sdk::signature::KeypairUtil; +use solana_sdk::{hash::Hash, pubkey::Pubkey, signature::KeypairUtil, signature::Signature}; use std::fs::remove_dir_all; +use std::str::FromStr; use std::sync::mpsc::channel; #[cfg(test)] @@ -71,6 +71,9 @@ fn test_cli_timestamp_tx() { timestamp_pubkey: Some(config_witness.keypair.pubkey()), witnesses: None, cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; let sig_response = process_command(&config_payer); @@ -138,6 +141,9 @@ fn test_cli_witness_tx() { timestamp_pubkey: None, witnesses: Some(vec![config_witness.keypair.pubkey()]), cancelable: false, + sign_only: false, + signers: None, + blockhash: None, }; let sig_response = process_command(&config_payer); @@ -198,6 +204,9 @@ fn test_cli_cancel_tx() { timestamp_pubkey: None, witnesses: Some(vec![config_witness.keypair.pubkey()]), cancelable: true, + sign_only: false, + signers: None, + blockhash: None, }; let sig_response = process_command(&config_payer).unwrap(); @@ -223,3 +232,94 @@ fn test_cli_cancel_tx() { server.close().unwrap(); remove_dir_all(ledger_path).unwrap(); } + +#[test] +fn test_offline_pay_tx() { + let (server, leader_data, alice, ledger_path) = new_validator_for_tests(); + let bob_pubkey = Pubkey::new_rand(); + + let (sender, receiver) = channel(); + run_local_drone(alice, sender, None); + let drone_addr = receiver.recv().unwrap(); + + let rpc_client = RpcClient::new_socket(leader_data.rpc); + + let mut config_offline = CliConfig::default(); + config_offline.json_rpc_url = + format!("http://{}:{}", leader_data.rpc.ip(), leader_data.rpc.port()); + let mut config_online = CliConfig::default(); + config_online.json_rpc_url = + format!("http://{}:{}", leader_data.rpc.ip(), leader_data.rpc.port()); + assert_ne!( + config_offline.keypair.pubkey(), + config_online.keypair.pubkey() + ); + + request_and_confirm_airdrop( + &rpc_client, + &drone_addr, + &config_offline.keypair.pubkey(), + 50, + ) + .unwrap(); + + request_and_confirm_airdrop( + &rpc_client, + &drone_addr, + &config_online.keypair.pubkey(), + 50, + ) + .unwrap(); + check_balance(50, &rpc_client, &config_offline.keypair.pubkey()); + check_balance(50, &rpc_client, &config_online.keypair.pubkey()); + + config_offline.command = CliCommand::Pay { + lamports: 10, + to: bob_pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: true, + signers: None, + blockhash: None, + }; + let sig_response = process_command(&config_offline).unwrap(); + + check_balance(50, &rpc_client, &config_offline.keypair.pubkey()); + check_balance(50, &rpc_client, &config_online.keypair.pubkey()); + check_balance(0, &rpc_client, &bob_pubkey); + + let object: Value = serde_json::from_str(&sig_response).unwrap(); + let blockhash_str = object.get("blockhash").unwrap().as_str().unwrap(); + let signer_strings = object.get("signers").unwrap().as_array().unwrap(); + let signers: Vec<_> = signer_strings + .iter() + .map(|signer_string| { + let mut signer = signer_string.as_str().unwrap().split('='); + let key = Pubkey::from_str(signer.next().unwrap()).unwrap(); + let sig = Signature::from_str(signer.next().unwrap()).unwrap(); + (key, sig) + }) + .collect(); + + config_online.command = CliCommand::Pay { + lamports: 10, + to: bob_pubkey, + timestamp: None, + timestamp_pubkey: None, + witnesses: None, + cancelable: false, + sign_only: false, + signers: Some(signers), + blockhash: Some(blockhash_str.parse::().unwrap()), + }; + process_command(&config_online).unwrap(); + + check_balance(40, &rpc_client, &config_offline.keypair.pubkey()); + check_balance(50, &rpc_client, &config_online.keypair.pubkey()); + check_balance(10, &rpc_client, &bob_pubkey); + + server.close().unwrap(); + remove_dir_all(ledger_path).unwrap(); +} diff --git a/sdk/src/instruction.rs b/sdk/src/instruction.rs index 138008ea65..6adab5b8ac 100644 --- a/sdk/src/instruction.rs +++ b/sdk/src/instruction.rs @@ -117,7 +117,7 @@ impl Instruction { pub struct AccountMeta { /// An account's public key pub pubkey: Pubkey, - /// True if an Instruciton requires a Transaction signature matching `pubkey`. + /// True if an Instruction requires a Transaction signature matching `pubkey`. pub is_signer: bool, /// True if the `pubkey` can be loaded as a read-write account. pub is_writable: bool, diff --git a/sdk/src/transaction.rs b/sdk/src/transaction.rs index 58946c946d..577a3ab894 100644 --- a/sdk/src/transaction.rs +++ b/sdk/src/transaction.rs @@ -51,6 +51,9 @@ pub enum TransactionError { /// Transaction contains an invalid account reference InvalidAccountIndex, + + /// Transaction did not pass signature verification + SignatureFailure, } pub type Result = result::Result; @@ -225,6 +228,21 @@ impl Transaction { } } + /// Verify the transaction + pub fn verify(&self) -> Result<()> { + if !self + .signatures + .iter() + .zip(&self.message.account_keys) + .map(|(signature, pubkey)| signature.verify(pubkey.as_ref(), &self.message_data())) + .all(|verify_result| verify_result) + { + Err(TransactionError::SignatureFailure) + } else { + Ok(()) + } + } + /// Get the positions of the pubkeys in `account_keys` associated with signing keypairs pub fn get_signing_keypair_positions( &self, @@ -246,6 +264,27 @@ impl Transaction { .collect()) } + /// Replace all the signatures and pubkeys + pub fn replace_signatures(&mut self, signers: &[(Pubkey, Signature)]) -> Result<()> { + let num_required_signatures = self.message.header.num_required_signatures as usize; + if signers.len() != num_required_signatures + || self.signatures.len() != num_required_signatures + || self.message.account_keys.len() < num_required_signatures + { + return Err(TransactionError::InvalidAccountIndex); + } + + signers + .iter() + .enumerate() + .for_each(|(i, (pubkey, signature))| { + self.signatures[i] = *signature; + self.message.account_keys[i] = *pubkey; + }); + + self.verify() + } + pub fn is_signed(&self) -> bool { self.signatures .iter()