diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 505b50ed6..6a6e0cecb 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -308,6 +308,12 @@ pub enum CliCommand { withdraw_amount: SpendAmount, memo: Option, }, + CloseVoteAccount { + vote_account_pubkey: Pubkey, + destination_account_pubkey: Pubkey, + withdraw_authority: SignerIndex, + memo: Option, + }, VoteAuthorize { vote_account_pubkey: Pubkey, new_authorized_pubkey: Pubkey, @@ -810,6 +816,9 @@ pub fn parse_command( ("withdraw-from-vote-account", Some(matches)) => { parse_withdraw_from_vote_account(matches, default_signer, wallet_manager) } + ("close-vote-account", Some(matches)) => { + parse_close_vote_account(matches, default_signer, wallet_manager) + } // Wallet Commands ("account", Some(matches)) => parse_account(matches, wallet_manager), ("address", Some(matches)) => Ok(CliCommandInfo { @@ -1409,6 +1418,19 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { destination_account_pubkey, memo.as_ref(), ), + CliCommand::CloseVoteAccount { + vote_account_pubkey, + withdraw_authority, + destination_account_pubkey, + memo, + } => process_close_vote_account( + &rpc_client, + config, + vote_account_pubkey, + *withdraw_authority, + destination_account_pubkey, + memo.as_ref(), + ), CliCommand::VoteAuthorize { vote_account_pubkey, new_authorized_pubkey, diff --git a/cli/src/vote.rs b/cli/src/vote.rs index 871d3faf2..1be6866cc 100644 --- a/cli/src/vote.rs +++ b/cli/src/vote.rs @@ -15,7 +15,7 @@ use solana_clap_utils::{ memo::{memo_arg, MEMO_ARG}, }; use solana_cli_output::{CliEpochVotingHistory, CliLockout, CliVoteAccount}; -use solana_client::rpc_client::RpcClient; +use solana_client::{rpc_client::RpcClient, rpc_config::RpcGetVoteAccountsConfig}; use solana_remote_wallet::remote_wallet::RemoteWalletManager; use solana_sdk::{ account::Account, commitment_config::CommitmentConfig, message::Message, @@ -335,7 +335,36 @@ impl VoteSubCommands for App<'_, '_> { .validator(is_valid_signer) .help("Authorized withdrawer [default: cli config keypair]"), ) - .arg(memo_arg()) + .arg(memo_arg() + ) + ) + .subcommand( + SubCommand::with_name("close-vote-account") + .about("Close a vote account and withdraw all funds remaining") + .arg( + pubkey!(Arg::with_name("vote_account_pubkey") + .index(1) + .value_name("VOTE_ACCOUNT_ADDRESS") + .required(true), + "Vote account to be closed. "), + ) + .arg( + pubkey!(Arg::with_name("destination_account_pubkey") + .index(2) + .value_name("RECIPIENT_ADDRESS") + .required(true), + "The recipient of all withdrawn SOL. "), + ) + .arg( + Arg::with_name("authorized_withdrawer") + .long("authorized-withdrawer") + .value_name("AUTHORIZED_KEYPAIR") + .takes_value(true) + .validator(is_valid_signer) + .help("Authorized withdrawer [default: cli config keypair]"), + ) + .arg(memo_arg() + ) ) } } @@ -554,6 +583,38 @@ pub fn parse_withdraw_from_vote_account( }) } +pub fn parse_close_vote_account( + matches: &ArgMatches<'_>, + default_signer: &DefaultSigner, + wallet_manager: &mut Option>, +) -> Result { + let vote_account_pubkey = + pubkey_of_signer(matches, "vote_account_pubkey", wallet_manager)?.unwrap(); + let destination_account_pubkey = + pubkey_of_signer(matches, "destination_account_pubkey", wallet_manager)?.unwrap(); + + let (withdraw_authority, withdraw_authority_pubkey) = + signer_of(matches, "authorized_withdrawer", wallet_manager)?; + + let payer_provided = None; + let signer_info = default_signer.generate_unique_signers( + vec![payer_provided, withdraw_authority], + matches, + wallet_manager, + )?; + let memo = matches.value_of(MEMO_ARG.name).map(String::from); + + Ok(CliCommandInfo { + command: CliCommand::CloseVoteAccount { + vote_account_pubkey, + destination_account_pubkey, + withdraw_authority: signer_info.index_of(withdraw_authority_pubkey).unwrap(), + memo, + }, + signers: signer_info.signers, + }) +} + pub fn process_create_vote_account( rpc_client: &RpcClient, config: &CliConfig, @@ -907,6 +968,62 @@ pub fn process_withdraw_from_vote_account( log_instruction_custom_error::(result, config) } +pub fn process_close_vote_account( + rpc_client: &RpcClient, + config: &CliConfig, + vote_account_pubkey: &Pubkey, + withdraw_authority: SignerIndex, + destination_account_pubkey: &Pubkey, + memo: Option<&String>, +) -> ProcessResult { + let vote_account_status = + rpc_client.get_vote_accounts_with_config(RpcGetVoteAccountsConfig { + vote_pubkey: Some(vote_account_pubkey.to_string()), + ..RpcGetVoteAccountsConfig::default() + })?; + + if let Some(vote_account) = vote_account_status + .current + .into_iter() + .chain(vote_account_status.delinquent.into_iter()) + .next() + { + if vote_account.activated_stake != 0 { + return Err(format!( + "Cannot close a vote account with active stake: {}", + vote_account_pubkey + ) + .into()); + } + } + + let latest_blockhash = rpc_client.get_latest_blockhash()?; + let withdraw_authority = config.signers[withdraw_authority]; + + let current_balance = rpc_client.get_balance(vote_account_pubkey)?; + + let ixs = vec![withdraw( + vote_account_pubkey, + &withdraw_authority.pubkey(), + current_balance, + destination_account_pubkey, + )] + .with_memo(memo); + + let message = Message::new(&ixs, Some(&config.signers[0].pubkey())); + let mut transaction = Transaction::new_unsigned(message); + transaction.try_sign(&config.signers, latest_blockhash)?; + check_account_for_fee_with_commitment( + rpc_client, + &config.signers[0].pubkey(), + &latest_blockhash, + &transaction.message, + config.commitment, + )?; + let result = rpc_client.send_and_confirm_transaction_with_spinner(&transaction); + log_instruction_custom_error::(result, config) +} + #[cfg(test)] mod tests { use super::*; @@ -1304,5 +1421,53 @@ mod tests { ], } ); + + // Test CloseVoteAccount subcommand + let test_close_vote_account = test_commands.clone().get_matches_from(vec![ + "test", + "close-vote-account", + &keypair_file, + &pubkey_string, + ]); + assert_eq!( + parse_command(&test_close_vote_account, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::CloseVoteAccount { + vote_account_pubkey: read_keypair_file(&keypair_file).unwrap().pubkey(), + destination_account_pubkey: pubkey, + withdraw_authority: 0, + memo: None, + }, + signers: vec![read_keypair_file(&default_keypair_file).unwrap().into()], + } + ); + + // Test CloseVoteAccount subcommand with authority + let withdraw_authority = Keypair::new(); + let (withdraw_authority_file, mut tmp_file) = make_tmp_file(); + write_keypair(&withdraw_authority, tmp_file.as_file_mut()).unwrap(); + let test_close_vote_account = test_commands.clone().get_matches_from(vec![ + "test", + "close-vote-account", + &keypair_file, + &pubkey_string, + "--authorized-withdrawer", + &withdraw_authority_file, + ]); + assert_eq!( + parse_command(&test_close_vote_account, &default_signer, &mut None).unwrap(), + CliCommandInfo { + command: CliCommand::CloseVoteAccount { + vote_account_pubkey: read_keypair_file(&keypair_file).unwrap().pubkey(), + destination_account_pubkey: pubkey, + withdraw_authority: 1, + memo: None, + }, + signers: vec![ + read_keypair_file(&default_keypair_file).unwrap().into(), + read_keypair_file(&withdraw_authority_file).unwrap().into() + ], + } + ); } } diff --git a/cli/tests/vote.rs b/cli/tests/vote.rs index 49336d9cb..f8ddc3937 100644 --- a/cli/tests/vote.rs +++ b/cli/tests/vote.rs @@ -147,7 +147,8 @@ fn test_vote_authorize_and_withdraw() { memo: None, }; process_command(&config).unwrap(); - check_recent_balance(expected_balance - 100, &rpc_client, &vote_account_pubkey); + let expected_balance = expected_balance - 100; + check_recent_balance(expected_balance, &rpc_client, &vote_account_pubkey); check_recent_balance(100, &rpc_client, &destination_account); // Re-assign validator identity @@ -160,4 +161,17 @@ fn test_vote_authorize_and_withdraw() { memo: None, }; process_command(&config).unwrap(); + + // Close vote account + let destination_account = solana_sdk::pubkey::new_rand(); // Send withdrawal to new account to make balance check easy + config.signers = vec![&default_signer, &withdraw_authority]; + config.command = CliCommand::CloseVoteAccount { + vote_account_pubkey, + withdraw_authority: 1, + destination_account_pubkey: destination_account, + memo: None, + }; + process_command(&config).unwrap(); + check_recent_balance(0, &rpc_client, &vote_account_pubkey); + check_recent_balance(expected_balance, &rpc_client, &destination_account); } diff --git a/docs/src/running-validator/vote-accounts.md b/docs/src/running-validator/vote-accounts.md index 7af56cc71..78fbc747a 100644 --- a/docs/src/running-validator/vote-accounts.md +++ b/docs/src/running-validator/vote-accounts.md @@ -210,3 +210,10 @@ migration. ### Vote Account Authorized Withdrawer No special handling is required. Use the `solana vote-authorize-withdrawer` command as needed. + +## Close a Vote Account + +A vote account can be closed with the +[close-vote-account](../cli/usage.md#solana-close-vote-account) command. +Closing a vote account withdraws all remaining SOL funds to a supplied recipient address and renders it invalid as a vote account. +It is not possible to close a vote account with active stake.