diff --git a/Cargo.lock b/Cargo.lock index 496820cb230467..ddf09d1ad08c52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5674,6 +5674,7 @@ dependencies = [ "assert_matches", "chrono", "clap 2.33.3", + "itertools", "rpassword", "solana-remote-wallet", "solana-sdk", @@ -5720,6 +5721,7 @@ dependencies = [ "log", "num-traits", "pretty-hex", + "rand 0.8.5", "reqwest", "semver 1.0.20", "serde", @@ -5751,6 +5753,7 @@ dependencies = [ "solana_rbpf", "spl-memo", "tempfile", + "test-case", "thiserror", "tiny-bip39", ] diff --git a/clap-utils/Cargo.toml b/clap-utils/Cargo.toml index c51dc0f1d4b060..2421e985ca5c35 100644 --- a/clap-utils/Cargo.toml +++ b/clap-utils/Cargo.toml @@ -19,6 +19,7 @@ thiserror = { workspace = true } tiny-bip39 = { workspace = true } uriparse = { workspace = true } url = { workspace = true } +itertools = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/clap-utils/src/input_parsers.rs b/clap-utils/src/input_parsers.rs index f28425bf5ad28c..73050b49303ba9 100644 --- a/clap-utils/src/input_parsers.rs +++ b/clap-utils/src/input_parsers.rs @@ -1,7 +1,8 @@ use { crate::keypair::{ keypair_from_seed_phrase, pubkey_from_path, resolve_signer_from_path, signer_from_path, - ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG, + signer_from_path_with_config, SignerFromPathConfig, ASK_KEYWORD, + SKIP_SEED_PHRASE_VALIDATION_ARG, }, chrono::DateTime, clap::ArgMatches, @@ -118,7 +119,7 @@ pub fn pubkeys_sigs_of(matches: &ArgMatches<'_>, name: &str) -> Option, @@ -134,6 +135,27 @@ pub fn signer_of( } } +// Return a signer from matches at `name` (returns NullSigner if no "real" signer can be extracted from matches arg). +#[allow(clippy::type_complexity)] +pub fn signer_of_or_null_signer( + matches: &ArgMatches<'_>, + name: &str, + wallet_manager: &mut Option>, +) -> Result<(Option>, Option), Box> { + if let Some(location) = matches.value_of(name) { + // Allow pubkey signers without accompanying signatures + let config = SignerFromPathConfig { + allow_null_signer: true, + }; + let signer = + signer_from_path_with_config(matches, location, name, wallet_manager, &config)?; + let signer_pubkey = signer.pubkey(); + Ok((Some(signer), Some(signer_pubkey))) + } else { + Ok((None, None)) + } +} + pub fn pubkey_of_signer( matches: &ArgMatches<'_>, name: &str, diff --git a/clap-utils/src/keypair.rs b/clap-utils/src/keypair.rs index ead51c9970ea93..bd123741ce6481 100644 --- a/clap-utils/src/keypair.rs +++ b/clap-utils/src/keypair.rs @@ -17,6 +17,7 @@ use { }, bip39::{Language, Mnemonic, Seed}, clap::ArgMatches, + itertools::Itertools, rpassword::prompt_password, solana_remote_wallet::{ locator::{Locator as RemoteWalletLocator, LocatorError as RemoteWalletLocatorError}, @@ -66,6 +67,7 @@ impl SignOnly { } pub type CliSigners = Vec>; pub type SignerIndex = usize; +#[derive(Debug)] pub struct CliSignerInfo { pub signers: CliSigners, } @@ -196,10 +198,13 @@ impl DefaultSigner { /// `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. + /// loaded. If multiple equivalent (same pub key) signers are provided - only + /// one of those will be returned in the result, such that NullSigner(s) + /// always get lower priority. /// /// The returned value includes all of the `bulk_signers` that were not - /// `None`, and maybe the default signer, if it was loaded. + /// `None`, and maybe the default signer (if it was loaded). There is no + /// guarantees on resulting signers ordering. /// /// # Examples /// @@ -245,17 +250,29 @@ impl DefaultSigner { 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); + // Group provided signers by pub key + for (_, mut signers) in &bulk_signers.into_iter().group_by(|signer| -> Pubkey { + if let Some(signer) = signer { + return signer.pubkey(); + } + Pubkey::default() + }) { + let best_signer = signers.next().unwrap(); // group can't have 0 elems + if best_signer.is_none() { + // If there is a group of None signers, we need to add default one. + let default_signer = self.signer_from_path(matches, wallet_manager)?; + unique_signers.push(default_signer); + continue; // nothing else to do for this group + } + let mut best_signer = best_signer.unwrap(); // can't be None here + for signer in signers.skip(1) { + let signer = signer.unwrap(); // can't be None here + if !signer.is_null_signer() { + best_signer = signer; + break; // prefer any signer over null signer + } } + unique_signers.push(best_signer); } Ok(CliSignerInfo { signers: unique_signers, diff --git a/cli-output/src/cli_output.rs b/cli-output/src/cli_output.rs index 7e51a05786fcfa..2d0ad6178c7c47 100644 --- a/cli-output/src/cli_output.rs +++ b/cli-output/src/cli_output.rs @@ -3154,6 +3154,7 @@ mod tests { #[test] fn test_return_signers() { + #[derive(Debug)] struct BadSigner { pubkey: Pubkey, } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 01d773ff9eaa4c..b9170ac79ab07c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -59,6 +59,7 @@ assert_matches = { workspace = true } solana-streamer = { workspace = true } solana-test-validator = { workspace = true } tempfile = { workspace = true } +test-case = { workspace = true } [[bin]] name = "solana" diff --git a/cli/src/program.rs b/cli/src/program.rs index 98cfa1c13bf2b2..59fa48987adb96 100644 --- a/cli/src/program.rs +++ b/cli/src/program.rs @@ -18,11 +18,13 @@ use { input_parsers::*, input_validators::*, keypair::*, + offline::{OfflineArgs, DUMP_TRANSACTION_MESSAGE, SIGN_ONLY_ARG}, }, solana_cli_output::{ - CliProgram, CliProgramAccountType, CliProgramAuthority, CliProgramBuffer, CliProgramId, - CliUpgradeableBuffer, CliUpgradeableBuffers, CliUpgradeableProgram, - CliUpgradeableProgramClosed, CliUpgradeableProgramExtended, CliUpgradeablePrograms, + return_signers_with_config, CliProgram, CliProgramAccountType, CliProgramAuthority, + CliProgramBuffer, CliProgramId, CliUpgradeableBuffer, CliUpgradeableBuffers, + CliUpgradeableProgram, CliUpgradeableProgramClosed, CliUpgradeableProgramExtended, + CliUpgradeablePrograms, ReturnSignersConfig, }, solana_client::{ connection_cache::ConnectionCache, @@ -40,6 +42,7 @@ use { config::{RpcAccountInfoConfig, RpcProgramAccountsConfig, RpcSendTransactionConfig}, filter::{Memcmp, RpcFilterType}, }, + solana_rpc_client_nonce_utils::blockhash_query::BlockhashQuery, solana_sdk::{ account::Account, account_utils::StateMut, @@ -88,6 +91,15 @@ pub enum ProgramCliCommand { allow_excessive_balance: bool, skip_fee_check: bool, }, + Upgrade { + fee_payer_signer_index: SignerIndex, + program_signer_index: SignerIndex, + buffer_signer_index: SignerIndex, + upgrade_authority_signer_index: SignerIndex, + sign_only: bool, + dump_transaction_message: bool, + blockhash_query: BlockhashQuery, + }, WriteBuffer { program_location: String, fee_payer_signer_index: SignerIndex, @@ -220,6 +232,38 @@ impl ProgramSubCommands for App<'_, '_> { ), ), ) + .subcommand( + SubCommand::with_name("upgrade") + .about("Upgrade an upgradeable program") + .arg( + Arg::with_name("buffer") + .index(1) + .required(true) + .value_name("BUFFER_PUBKEY") + .takes_value(true) + .validator(is_valid_pubkey) + .help("Intermediate buffer account with new program data"), + ) + .arg( + Arg::with_name("upgrade_authority") + .long("upgrade-authority") + .value_name("UPGRADE_AUTHORITY_SIGNER") + .takes_value(true) + .validator(is_valid_signer) + .help( + "Upgrade authority [default: the default configured keypair]", + ), + ) + .arg(pubkey!( + Arg::with_name("program_id") + .index(2) + .required(true) + .value_name("PROGRAM_ID"), + "Executable program's address (pubkey)" + )) + .arg(fee_payer_arg()) + .offline_args(), + ) .subcommand( SubCommand::with_name("write-buffer") .about("Writes a program into a buffer account") @@ -571,6 +615,75 @@ pub fn parse_program_subcommand( signers: signer_info.signers, } } + ("upgrade", Some(matches)) => { + let sign_only = matches.is_present(SIGN_ONLY_ARG.name); + let dump_transaction_message = matches.is_present(DUMP_TRANSACTION_MESSAGE.name); + let blockhash_query = BlockhashQuery::new_from_matches(matches); + + let mut bulk_signers = vec![]; + + let fee_payer_pubkey = if let Ok((Some(fee_payer_signer), Some(fee_payer_pubkey))) = + signer_of_or_null_signer(matches, FEE_PAYER_ARG.name, wallet_manager) + { + bulk_signers.push(Some(fee_payer_signer)); + fee_payer_pubkey + } else { + let fee_payer_signer = default_signer.signer_from_path(matches, wallet_manager)?; + let fee_payer_pubkey = fee_payer_signer.pubkey(); + bulk_signers.push(Some(fee_payer_signer)); + fee_payer_pubkey + }; + + let buffer_pubkey = if let Ok(Some(buffer_pubkey)) = + pubkey_of_signer(matches, "buffer", wallet_manager) + { + Some(buffer_pubkey) + } else { + return Err(CliError::BadParameter( + "`BUFFER_PUBKEY` must be specified when doing program upgrade".into(), + )); + }; + + let upgrade_authority_pubkey = + if let Ok((upgrade_authority, Some(upgrade_authority_pubkey))) = + signer_of_or_null_signer(matches, "buffer", wallet_manager) + { + bulk_signers.push(upgrade_authority); + Some(upgrade_authority_pubkey) + } else { + return Err(CliError::BadParameter( + "`--upgrade-authority` must be specified when doing program upgrade".into(), + )); + }; + + let program_pubkey = if let Ok(Some(program_pubkey)) = + pubkey_of_signer(matches, "program_id", wallet_manager) + { + Some(program_pubkey) + } else { + return Err(CliError::BadParameter( + "`PROGRAM_ID` must be specified when doing program upgrade".into(), + )); + }; + + let signer_info = + default_signer.generate_unique_signers(bulk_signers, matches, wallet_manager)?; + + CliCommandInfo { + command: CliCommand::Program(ProgramCliCommand::Upgrade { + fee_payer_signer_index: signer_info.index_of(Some(fee_payer_pubkey)).unwrap(), + program_signer_index: signer_info.index_of(program_pubkey).unwrap(), + buffer_signer_index: signer_info.index_of(buffer_pubkey).unwrap(), + upgrade_authority_signer_index: signer_info + .index_of(upgrade_authority_pubkey) + .unwrap(), + sign_only, + dump_transaction_message, + blockhash_query, + }), + signers: signer_info.signers, + } + } ("write-buffer", Some(matches)) => { let (fee_payer, fee_payer_pubkey) = signer_of(matches, FEE_PAYER_ARG.name, wallet_manager)?; @@ -816,6 +929,25 @@ pub fn process_program_subcommand( *allow_excessive_balance, *skip_fee_check, ), + ProgramCliCommand::Upgrade { + fee_payer_signer_index, + program_signer_index, + buffer_signer_index, + upgrade_authority_signer_index, + sign_only, + dump_transaction_message, + blockhash_query, + } => process_program_upgrade( + rpc_client, + config, + *fee_payer_signer_index, + *program_signer_index, + *buffer_signer_index, + *upgrade_authority_signer_index, + *sign_only, + *dump_transaction_message, + blockhash_query, + ), ProgramCliCommand::WriteBuffer { program_location, fee_payer_signer_index, @@ -1175,6 +1307,71 @@ fn fetch_buffer_len( } } +/// Upgrade existing program using upgradeable loader +#[allow(clippy::too_many_arguments)] +fn process_program_upgrade( + rpc_client: Arc, + config: &CliConfig, + fee_payer_signer_index: SignerIndex, + program_signer_index: SignerIndex, + buffer_signer_index: SignerIndex, + upgrade_authority_signer_index: SignerIndex, + sign_only: bool, + dump_transaction_message: bool, + blockhash_query: &BlockhashQuery, +) -> ProcessResult { + let fee_payer_signer = config.signers[fee_payer_signer_index]; + let upgrade_authority_signer = config.signers[upgrade_authority_signer_index]; + let buffer_pubkey = config.signers[buffer_signer_index].pubkey(); + let program_pubkey = config.signers[program_signer_index].pubkey(); + + let blockhash = blockhash_query.get_blockhash(&rpc_client, config.commitment)?; + let message = Message::new_with_blockhash( + &[bpf_loader_upgradeable::upgrade( + &program_pubkey, + &buffer_pubkey, + &upgrade_authority_signer.pubkey(), + &fee_payer_signer.pubkey(), + )], + Some(&fee_payer_signer.pubkey()), + &blockhash, + ); + + if sign_only { + let mut tx = Transaction::new_unsigned(message); + let signers = &[fee_payer_signer, upgrade_authority_signer]; + // Using try_partial_sign here because fee_payer_signer might not be the fee payer we + // end up using for this transaction (it might be NullSigner). + tx.try_partial_sign(signers, blockhash)?; + return_signers_with_config( + &tx, + &config.output_format, + &ReturnSignersConfig { + dump_transaction_message, + }, + ) + } else { + let fee = rpc_client.get_fee_for_message(&message)?; + check_account_for_spend_and_fee_with_commitment( + &rpc_client, + &fee_payer_signer.pubkey(), + 0, + fee, + config.commitment, + )?; + let mut tx = Transaction::new_unsigned(message); + let signers = &[fee_payer_signer, upgrade_authority_signer]; + tx.try_sign(signers, blockhash)?; + rpc_client + .send_and_confirm_transaction_with_spinner(&tx) + .map_err(|e| format!("Upgrading program failed: {e}"))?; + let program_id = CliProgramId { + program_id: program_pubkey.to_string(), + }; + Ok(config.output_format.formatted_string(&program_id)) + } +} + fn process_write_buffer( rpc_client: Arc, config: &CliConfig, diff --git a/cli/tests/program.rs b/cli/tests/program.rs index 7e8d409e1c26ec..10a88c14f9114e 100644 --- a/cli/tests/program.rs +++ b/cli/tests/program.rs @@ -1,4 +1,6 @@ #![allow(clippy::arithmetic_side_effects)] +// REMOVE once https://github.com/rust-lang/rust-clippy/issues/11153 is fixed +#![allow(clippy::items_after_test_module)] use { serde_json::Value, @@ -7,19 +9,27 @@ use { program::{ProgramCliCommand, CLOSE_PROGRAM_WARNING}, test_utils::wait_n_slots, }, - solana_cli_output::OutputFormat, + solana_cli_output::{parse_sign_only_reply_string, OutputFormat}, solana_faucet::faucet::run_local_faucet, solana_rpc_client::rpc_client::RpcClient, + solana_rpc_client_nonce_utils::blockhash_query::BlockhashQuery, solana_sdk::{ account_utils::StateMut, bpf_loader_upgradeable::{self, UpgradeableLoaderState}, commitment_config::CommitmentConfig, pubkey::Pubkey, - signature::{Keypair, Signer}, + signature::{Keypair, NullSigner, Signer}, }, solana_streamer::socket::SocketAddrSpace, solana_test_validator::TestValidator, - std::{env, fs::File, io::Read, path::PathBuf, str::FromStr}, + std::{ + env, + fs::File, + io::Read, + path::{Path, PathBuf}, + str::FromStr, + }, + test_case::test_case, }; #[test] @@ -1411,6 +1421,216 @@ fn test_cli_program_mismatch_buffer_authority() { process_command(&config).unwrap(); } +// Assume fee payer will be either online signer or offline signer (could be completely +// separate signer too, but that option is unlikely to be chosen often, so don't bother +// testing for it), we want to test for most common choices. +#[test_case(true; "offline signer will be fee payer")] +#[test_case(false; "online signer will be fee payer")] +fn test_cli_program_deploy_with_offline_signing(use_offline_signer_as_fee_payer: bool) { + solana_logger::setup(); + + let mut noop_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + noop_path.push("tests"); + noop_path.push("fixtures"); + noop_path.push("noop"); + noop_path.set_extension("so"); + + let mut noop_large_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + noop_large_path.push("tests"); + noop_large_path.push("fixtures"); + noop_large_path.push("noop_large"); + noop_large_path.set_extension("so"); + + let mint_keypair = Keypair::new(); + let mint_pubkey = mint_keypair.pubkey(); + let faucet_addr = run_local_faucet(mint_keypair, None); + let test_validator = + TestValidator::with_no_fees(mint_pubkey, Some(faucet_addr), SocketAddrSpace::Unspecified); + + let rpc_client = + RpcClient::new_with_commitment(test_validator.rpc_url(), CommitmentConfig::processed()); + + let blockhash = rpc_client.get_latest_blockhash().unwrap(); + + let mut file = File::open(noop_large_path.to_str().unwrap()).unwrap(); + let mut large_program_data = Vec::new(); + file.read_to_end(&mut large_program_data).unwrap(); + let max_program_data_len = large_program_data.len(); + let minimum_balance_for_large_buffer = rpc_client + .get_minimum_balance_for_rent_exemption(UpgradeableLoaderState::size_of_programdata( + max_program_data_len, + )) + .unwrap(); + + let mut config = CliConfig::recent_for_tests(); + config.json_rpc_url = test_validator.rpc_url(); + + let online_signer = Keypair::new(); + let online_signer_identity = NullSigner::new(&online_signer.pubkey()); + let offline_signer = Keypair::new(); + let buffer_signer = Keypair::new(); + let buffer_signer_identity = NullSigner::new(&buffer_signer.pubkey()); + // Typically, keypair for program signer should be different from online signer or + // offline signer keypairs. + let program_signer = Keypair::new(); + let program_signer_identity = NullSigner::new(&program_signer.pubkey()); + + config.command = CliCommand::Airdrop { + pubkey: None, + lamports: 100 * minimum_balance_for_large_buffer, // gotta be enough for this test + }; + config.signers = vec![&online_signer]; + process_command(&config).unwrap(); + config.command = CliCommand::Airdrop { + pubkey: None, + lamports: 100 * minimum_balance_for_large_buffer, // gotta be enough for this test + }; + config.signers = vec![&offline_signer]; + process_command(&config).unwrap(); + + // Deploy upgradeable program with authority set to offline signer + config.signers = vec![&online_signer, &offline_signer, &program_signer]; + config.command = CliCommand::Program(ProgramCliCommand::Deploy { + program_location: Some(noop_path.to_str().unwrap().to_string()), + fee_payer_signer_index: 0, + program_signer_index: Some(2), + program_pubkey: Some(program_signer.pubkey()), + buffer_signer_index: None, + buffer_pubkey: None, + allow_excessive_balance: false, + upgrade_authority_signer_index: 1, // must be offline signer for security reasons + is_final: false, + max_len: Some(max_program_data_len), // allows for larger program size with future upgrades + skip_fee_check: false, + }); + config.output_format = OutputFormat::JsonCompact; + process_command(&config).unwrap(); + + // Prepare buffer to upgrade deployed program to a larger program + create_buffer_with_offline_authority( + &rpc_client, + &noop_large_path, + &mut config, + &online_signer, + &offline_signer, + &buffer_signer, + ); + + // Offline sign-only with signature over "wrong" message (with different buffer) + config.signers = vec![ + &offline_signer, + &buffer_signer_identity, + &program_signer_identity, + ]; + let fee_payer_signer_index = if use_offline_signer_as_fee_payer { + 0 // offline signer + } else { + config.signers.push(&online_signer_identity); // can't (and won't) provide signature in --sign-only mode + 3 // online signer + }; + config.command = CliCommand::Program(ProgramCliCommand::Upgrade { + fee_payer_signer_index, + program_signer_index: 2, + buffer_signer_index: 2, // will ensure offline signature applies to wrong(different) message + upgrade_authority_signer_index: 0, + sign_only: true, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::new(Some(blockhash), true, None), + }); + config.output_format = OutputFormat::JsonCompact; + let sig_response = process_command(&config).unwrap(); + let sign_only = parse_sign_only_reply_string(&sig_response); + let offline_pre_signer = sign_only.presigner_of(&offline_signer.pubkey()).unwrap(); + // Attempt to deploy from buffer using signature over wrong(different) message (should fail) + config.signers = vec![ + &offline_pre_signer, + &buffer_signer_identity, + &program_signer, + ]; + let fee_payer_signer_index = if use_offline_signer_as_fee_payer { + 0 // offline signer + } else { + config.signers.push(&online_signer); // can provide signature when not in --sign-only mode + 3 // online signer + }; + config.command = CliCommand::Program(ProgramCliCommand::Upgrade { + fee_payer_signer_index, + program_signer_index: 2, + buffer_signer_index: 1, + upgrade_authority_signer_index: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::new(Some(blockhash), true, None), + }); + config.output_format = OutputFormat::JsonCompact; + let error = process_command(&config).unwrap_err(); + assert_eq!(error.to_string(), "presigner error"); + + // Offline sign-only with online signer as fee payer (correct signature for program upgrade) + config.signers = vec![ + &offline_signer, + &buffer_signer_identity, + &program_signer_identity, + ]; + let fee_payer_signer_index = if use_offline_signer_as_fee_payer { + 0 // offline signer + } else { + config.signers.push(&online_signer_identity); // can't (and won't) provide signature in --sign-only mode + 3 // online signer + }; + config.command = CliCommand::Program(ProgramCliCommand::Upgrade { + fee_payer_signer_index, + program_signer_index: 2, + buffer_signer_index: 1, + upgrade_authority_signer_index: 0, + sign_only: true, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::new(Some(blockhash), true, None), + }); + config.output_format = OutputFormat::JsonCompact; + let sig_response = process_command(&config).unwrap(); + let sign_only = parse_sign_only_reply_string(&sig_response); + let offline_pre_signer = sign_only.presigner_of(&offline_signer.pubkey()).unwrap(); + // Attempt to deploy from buffer using signature over correct message (should succeed) + config.signers = vec![ + &offline_pre_signer, + &buffer_signer_identity, + &program_signer, + ]; + let fee_payer_signer_index = if use_offline_signer_as_fee_payer { + 0 // offline signer + } else { + config.signers.push(&online_signer); // can provide signature when not in --sign-only mode + 3 // online signer + }; + config.command = CliCommand::Program(ProgramCliCommand::Upgrade { + fee_payer_signer_index, + program_signer_index: 2, + buffer_signer_index: 1, + upgrade_authority_signer_index: 0, + sign_only: false, + dump_transaction_message: false, + blockhash_query: BlockhashQuery::new(Some(blockhash), true, None), + }); + config.output_format = OutputFormat::JsonCompact; + process_command(&config).unwrap(); + let (programdata_pubkey, _) = Pubkey::find_program_address( + &[program_signer.pubkey().as_ref()], + &bpf_loader_upgradeable::id(), + ); + let programdata_account = rpc_client.get_account(&programdata_pubkey).unwrap(); + assert_eq!( + programdata_account.lamports, + minimum_balance_for_large_buffer + ); + assert_eq!(programdata_account.owner, bpf_loader_upgradeable::id()); + assert!(!programdata_account.executable); + assert_eq!( + programdata_account.data[UpgradeableLoaderState::size_of_programdata_metadata()..], + large_program_data[..] + ); +} + #[test] fn test_cli_program_show() { solana_logger::setup(); @@ -1678,3 +1898,59 @@ fn test_cli_program_dump() { assert_eq!(program_data[i], out_data[i]); } } + +fn create_buffer_with_offline_authority<'a>( + rpc_client: &RpcClient, + program_path: &Path, + config: &mut CliConfig<'a>, + online_signer: &'a Keypair, + offline_signer: &'a Keypair, + buffer_signer: &'a Keypair, +) { + // Write a buffer + config.signers = vec![online_signer, buffer_signer]; + config.command = CliCommand::Program(ProgramCliCommand::WriteBuffer { + program_location: program_path.to_str().unwrap().to_string(), + fee_payer_signer_index: 0, + buffer_signer_index: Some(1), + buffer_pubkey: Some(buffer_signer.pubkey()), + buffer_authority_signer_index: 0, + max_len: None, + skip_fee_check: false, + }); + process_command(config).unwrap(); + let buffer_account = rpc_client.get_account(&buffer_signer.pubkey()).unwrap(); + if let UpgradeableLoaderState::Buffer { authority_address } = buffer_account.state().unwrap() { + assert_eq!(authority_address, Some(online_signer.pubkey())); + } else { + panic!("not a buffer account"); + } + + // Set buffer authority to offline signer + config.signers = vec![online_signer, buffer_signer]; + config.command = CliCommand::Program(ProgramCliCommand::SetBufferAuthority { + buffer_pubkey: buffer_signer.pubkey(), + buffer_authority_index: Some(0), + new_buffer_authority: offline_signer.pubkey(), + }); + config.output_format = OutputFormat::JsonCompact; + let response = process_command(config); + let json: Value = serde_json::from_str(&response.unwrap()).unwrap(); + let offline_signer_authority_str = json + .as_object() + .unwrap() + .get("authority") + .unwrap() + .as_str() + .unwrap(); + assert_eq!( + Pubkey::from_str(offline_signer_authority_str).unwrap(), + offline_signer.pubkey() + ); + let buffer_account = rpc_client.get_account(&buffer_signer.pubkey()).unwrap(); + if let UpgradeableLoaderState::Buffer { authority_address } = buffer_account.state().unwrap() { + assert_eq!(authority_address, Some(offline_signer.pubkey())); + } else { + panic!("not a buffer account"); + } +} diff --git a/docs/src/cli/deploy-a-program.md b/docs/src/cli/deploy-a-program.md index 3b2c89bf454ea2..f6adb553a07ef7 100644 --- a/docs/src/cli/deploy-a-program.md +++ b/docs/src/cli/deploy-a-program.md @@ -303,3 +303,52 @@ During deployment, the buffer account's contents are copied into the program-dat the buffer account is set to zero. The lamports from the buffer account are refunded to a spill account. Buffers also support `show` and `dump` just like programs do. + +### Upgrading program using offline signer as authority + +Some security models require separating the signing process from the transaction broadcast, such that the signing keys can be completely disconnected from any network, also known as [offline signing](../offline-signing.md). + +This section describes how a program developer can use offline signing to upgrade their program, unlike the [previous section](deploy-a-program.md#redeploy-a-program), which assumes the machine is connected to the internet, aka online signing. + +Note that only the `upgrade` command can be performed in offline mode. The initial program deployment **must** be performed from an online machine, and only subsequent program upgrades can leverage offline signing. + +Assuming your program has been deployed and its upgrade authority has been changed to an +offline signer, +a typical setup would consist of 2 different signers: +- online signer (fee payer for uploading program buffer and upgrading program) +- offline signer (program upgrade authority) + +The general process is as follows: +1. (online) create buffer and write new program to it +2. (online) upgrade buffer authority to offline signer +3. (optional, online) verify the actual buffer on-chain contents +4. (offline) sign a transaction to upgrade the program +5. (online) use this signature to broadcast upgrade transaction + +```bash +# (1) (use online machine) create buffer +solana program write-buffer + +# (2) (use online machine) upgrade buffer authority to offline signer +solana program set-buffer-authority --new-buffer-authority +``` + +(3) (optional) You may verify that the uploaded program matches the built binary. See +[dumping a program to a file](deploy-a-program.md#dumping-a-program-to-a-file) for more information and details. + +```bash +# or (4) (use offline machine) get a signature for your intent to upgrade program +solana program upgrade --sign-only --fee-payer --upgrade-authority --blockhash + +# or (5) (use online machine) use this signature to build and broadcast upgrade transactions on-chain +solana program upgrade --fee-payer --upgrade-authority --blockhash --signer : +``` +Note: +- typically, the output of the previous command(s) will contain some values useful in subsequent commands, e.g. + `--program-id`, `--buffer`, `--signer` +- you need to specify matching (or corresponding) values for params with same names (`--fee-payer`, `--program-id`, + `--upgrade-authority`, `--buffer`, `--blockhash`) in offline/online modes +- you should pre-fill every value except for `blockhash` ahead of time, and once you are ready to act - you'll need to + look up fresh `blockhash` and paste it in quickly + do your signing, you have ~60 seconds before `blockhash` expires. + If you didn't make it in time - just get another fresh hash and repeat until you succeed, or consider using + [durable transaction nonces](../offline-signing/durable-nonce.md). diff --git a/remote-wallet/src/remote_keypair.rs b/remote-wallet/src/remote_keypair.rs index d37eefe2427175..9516e13d900bf4 100644 --- a/remote-wallet/src/remote_keypair.rs +++ b/remote-wallet/src/remote_keypair.rs @@ -14,6 +14,7 @@ use { }, }; +#[derive(Debug)] pub struct RemoteKeypair { pub wallet_type: RemoteWalletType, pub derivation_path: DerivationPath, diff --git a/sdk/src/signer/mod.rs b/sdk/src/signer/mod.rs index 710860e231981b..393642cfed5dec 100644 --- a/sdk/src/signer/mod.rs +++ b/sdk/src/signer/mod.rs @@ -12,6 +12,7 @@ use { itertools::Itertools, std::{ error, + fmt::Debug, fs::{self, File, OpenOptions}, io::{Read, Write}, ops::Deref, @@ -66,7 +67,7 @@ pub enum SignerError { /// The `Signer` trait declares operations that all digital signature providers /// must support. It is the primary interface by which signers are specified in /// `Transaction` signing interfaces -pub trait Signer { +pub trait Signer: Debug { /// Infallibly gets the implementor's public key. Returns the all-zeros /// `Pubkey` if the implementor has none. fn pubkey(&self) -> Pubkey { @@ -81,8 +82,12 @@ pub trait Signer { } /// Fallibly produces an Ed25519 signature over the provided `message` bytes. fn try_sign_message(&self, message: &[u8]) -> Result; - /// Whether the impelmentation requires user interaction to sign + /// Whether the implementation requires user interaction to sign fn is_interactive(&self) -> bool; + /// Whether the implementation is NullSigner + fn is_null_signer(&self) -> bool { + false // default reusable code + } } impl From for Box @@ -94,7 +99,8 @@ where } } -impl> Signer for Container { +/// This impl allows using Signer with types like Box/Rc/Arc. +impl + Debug> Signer for Container { #[inline] fn pubkey(&self) -> Pubkey { self.deref().pubkey() @@ -125,12 +131,6 @@ impl PartialEq for dyn Signer { impl Eq for dyn Signer {} -impl std::fmt::Debug for dyn Signer { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(fmt, "Signer: {:?}", self.pubkey()) - } -} - /// Removes duplicate signers while preserving order. O(n²) pub fn unique_signers(signers: Vec<&dyn Signer>) -> Vec<&dyn Signer> { signers.into_iter().unique_by(|s| s.pubkey()).collect() diff --git a/sdk/src/signer/null_signer.rs b/sdk/src/signer/null_signer.rs index 2e9508511832fd..e0642508e184e3 100644 --- a/sdk/src/signer/null_signer.rs +++ b/sdk/src/signer/null_signer.rs @@ -32,6 +32,10 @@ impl Signer for NullSigner { fn is_interactive(&self) -> bool { false } + + fn is_null_signer(&self) -> bool { + true + } } impl PartialEq for NullSigner diff --git a/sdk/src/signer/signers.rs b/sdk/src/signer/signers.rs index f4cfc7dc9618a0..f8f9ab06b622a2 100644 --- a/sdk/src/signer/signers.rs +++ b/sdk/src/signer/signers.rs @@ -15,6 +15,7 @@ pub trait Signers { fn sign_message(&self, message: &[u8]) -> Vec; fn try_sign_message(&self, message: &[u8]) -> Result, SignerError>; fn is_interactive(&self) -> bool; + fn is_null_signer(&self) -> bool; } macro_rules! default_keypairs_impl { @@ -48,6 +49,10 @@ macro_rules! default_keypairs_impl { fn is_interactive(&self) -> bool { self.iter().any(|s| s.is_interactive()) } + + fn is_null_signer(&self) -> bool { + self.iter().any(|s| s.is_null_signer()) + } }; } @@ -147,6 +152,7 @@ impl Signers for Vec<&T> { mod tests { use super::*; + #[derive(Debug)] struct Foo; impl Signer for Foo { fn try_pubkey(&self) -> Result { @@ -160,6 +166,7 @@ mod tests { } } + #[derive(Debug)] struct Bar; impl Signer for Bar { fn try_pubkey(&self) -> Result {