From fd952239a91456075f0f3925de978481e598e639 Mon Sep 17 00:00:00 2001 From: Gabriel Hicks <60371583+gabrielhicks@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:30:59 -0500 Subject: [PATCH 1/3] (feat): Add passthrough for increase and decrease and specific additional commands --- clients/cli/src/client.rs | 47 ++++++- clients/cli/src/main.rs | 277 +++++++++++++++++++++++++++++++++++++- 2 files changed, 320 insertions(+), 4 deletions(-) diff --git a/clients/cli/src/client.rs b/clients/cli/src/client.rs index c4beb6d9..57de03b7 100644 --- a/clients/cli/src/client.rs +++ b/clients/cli/src/client.rs @@ -14,8 +14,8 @@ use { solana_sdk::{compute_budget::ComputeBudgetInstruction, transaction::Transaction}, solana_stake_interface as stake, spl_stake_pool::{ - find_withdraw_authority_program_address, - state::{StakePool, ValidatorList}, + find_ephemeral_stake_program_address, find_withdraw_authority_program_address, + state::{StakePool, ValidatorList, ValidatorStakeInfo}, }, std::collections::HashSet, }; @@ -184,3 +184,46 @@ pub(crate) fn add_compute_unit_limit_from_simulation( .data = ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit).data; Ok(()) } + +/// Helper function to find an unused ephemeral stake account by incrementing seeds +/// starting from 0 until an uninitialized account is found +pub(crate) fn find_unused_ephemeral_stake_seed( + rpc_client: &RpcClient, + stake_pool_program_id: &Pubkey, + stake_pool_address: &Pubkey, + max_attempts: u64, +) -> Result { + for seed in 0..max_attempts { + let (ephemeral_stake_address, _) = + find_ephemeral_stake_program_address(stake_pool_program_id, stake_pool_address, seed); + + // Check if the account exists and is initialized + match rpc_client.get_account(&ephemeral_stake_address) { + Ok(account) => { + // Account exists - check if it's initialized (has non-zero data) + if account.data.iter().all(|&x| x == 0) { + // Account exists but is uninitialized, can use this seed + return Ok(seed); + } + // Account is initialized, try next seed + continue; + } + Err(_) => { + // Account doesn't exist, can use this seed + return Ok(seed); + } + } + } + + Err(format!( + "Could not find an unused ephemeral stake account after {} attempts. \ + Consider using a higher limit or cleaning up existing ephemeral accounts.", + max_attempts + ) + .into()) +} + +/// Check if a validator's transient stake account is currently in use +pub(crate) fn is_transient_stake_in_use(validator_stake_info: &ValidatorStakeInfo) -> bool { + u64::from(validator_stake_info.transient_stake_lamports) > 0 +} diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 370f90e3..6037def4 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -887,6 +887,7 @@ fn command_increase_validator_stake( stake_pool_address: &Pubkey, vote_account: &Pubkey, lamports: u64, + allow_additional_on_transient_busy: bool, ) -> CommandResult { if !config.no_update { command_update(config, stake_pool_address, false, false, false)?; @@ -899,6 +900,28 @@ fn command_increase_validator_stake( .ok_or("Vote account not found in validator list")?; let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + // Check if transient stake is in use + if is_transient_stake_in_use(validator_stake_info) { + if allow_additional_on_transient_busy { + println!("Transient stake account is in use. Attempting to use additional validator stake with ephemeral account..."); + return command_increase_additional_validator_stake( + config, + stake_pool_address, + vote_account, + lamports, + None, + ); + } else { + return Err(format!( + "Transient stake account is already in use for validator {}. \ + Use --allow-additional flag to automatically create an ephemeral account, \ + or use the increase-additional-validator-stake command directly.", + vote_account + ) + .into()); + } + } + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; unique_signers!(signers); let transaction = checked_transaction_with_signers( @@ -925,6 +948,7 @@ fn command_decrease_validator_stake( stake_pool_address: &Pubkey, vote_account: &Pubkey, lamports: u64, + allow_additional_on_transient_busy: bool, ) -> CommandResult { if !config.no_update { command_update(config, stake_pool_address, false, false, false)?; @@ -937,6 +961,28 @@ fn command_decrease_validator_stake( .ok_or("Vote account not found in validator list")?; let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + // Check if transient stake is in use + if is_transient_stake_in_use(validator_stake_info) { + if allow_additional_on_transient_busy { + println!("Transient stake account is in use. Attempting to use additional validator stake with ephemeral account..."); + return command_decrease_additional_validator_stake( + config, + stake_pool_address, + vote_account, + lamports, + None, + ); + } else { + return Err(format!( + "Transient stake account is already in use for validator {}. \ + Use --allow-additional flag to automatically create an ephemeral account, \ + or use the decrease-additional-validator-stake command directly.", + vote_account + ) + .into()); + } + } + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; unique_signers!(signers); let transaction = checked_transaction_with_signers( @@ -983,6 +1029,95 @@ fn command_set_preferred_validator( Ok(()) } +fn command_increase_additional_validator_stake( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, + lamports: u64, + ephemeral_stake_seed: Option, +) -> CommandResult { + let ephemeral_stake_seed = match ephemeral_stake_seed { + Some(seed) => seed, + None => { + // Find an unused ephemeral stake seed + let seed = find_unused_ephemeral_stake_seed( + &config.rpc_client, + &config.stake_pool_program_id, + stake_pool_address, + 1000, // Max attempts to find unused seed + )?; + seed + } + }; + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + + let instruction = spl_stake_pool::instruction::increase_additional_validator_stake_with_list( + &config.stake_pool_program_id, + &stake_pool, + &validator_list, + stake_pool_address, + vote_account, + lamports, + ephemeral_stake_seed, + )?; + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &[instruction], &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_decrease_additional_validator_stake( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, + lamports: u64, + ephemeral_stake_seed: Option, +) -> CommandResult { + let ephemeral_stake_seed = match ephemeral_stake_seed { + Some(seed) => seed, + None => { + // Find an unused ephemeral stake seed + let seed = find_unused_ephemeral_stake_seed( + &config.rpc_client, + &config.stake_pool_program_id, + stake_pool_address, + 1000, // Max attempts to find unused seed + )?; + println!("Using ephemeral stake seed: {}", seed); + seed + } + }; + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + + let instruction = spl_stake_pool::instruction::decrease_additional_validator_stake_with_list( + &config.stake_pool_program_id, + &stake_pool, + &validator_list, + stake_pool_address, + vote_account, + lamports, + ephemeral_stake_seed, + )?; + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &[instruction], &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + fn add_associated_token_account( config: &Config, mint: &Pubkey, @@ -2624,6 +2759,13 @@ fn main() { .takes_value(true) .help("Amount in SOL to add to the validator stake account. Must be at least the rent-exempt amount for a stake plus 1 SOL for merging."), ) + .arg( + Arg::with_name("allow_additional") + .long("allow-additional") + .short("aa") + .takes_value(false) + .help("Allow automatic fallback to additional validator stake with ephemeral accounts if transient stake is in use"), + ) ) .subcommand(SubCommand::with_name("decrease-validator-stake") .about("Decrease stake to a validator, splitting from the active stake. Must be signed by the pool staker.") @@ -2653,6 +2795,13 @@ fn main() { .takes_value(true) .help("Amount in SOL to remove from the validator stake account. Must be at least the rent-exempt amount for a stake."), ) + .arg( + Arg::with_name("allow_additional") + .long("allow-additional") + .short("aa") + .takes_value(false) + .help("Allow automatic fallback to additional validator stake with ephemeral accounts if transient stake is in use"), + ) ) .subcommand(SubCommand::with_name("set-preferred-validator") .about("Set the preferred validator for deposits or withdrawals. Must be signed by the pool staker.") @@ -2694,6 +2843,80 @@ fn main() { .required(true) ) ) + .subcommand(SubCommand::with_name("increase-additional-validator-stake") + .about("Increase stake to a validator again in the same epoch. Must be signed by the pool staker.") + .arg( + Arg::with_name("pool") + .index(1) + .validator(is_pubkey) + .value_name("POOL_ADDRESS") + .takes_value(true) + .required(true) + .help("Stake pool address"), + ) + .arg( + Arg::with_name("vote_account") + .index(2) + .validator(is_pubkey) + .value_name("VOTE_ACCOUNT_ADDRESS") + .takes_value(true) + .required(true) + .help("Vote account for the validator to increase stake to"), + ) + .arg( + Arg::with_name("amount") + .index(3) + .validator(is_amount) + .value_name("AMOUNT") + .takes_value(true) + .help("Amount in SOL to add to the validator stake account using ephemeral accounts."), + ) + .arg( + Arg::with_name("ephemeral_stake_seed") + .long("ephemeral-seed") + .validator(is_parsable::) + .value_name("SEED") + .takes_value(true) + .help("Specific ephemeral stake seed to use. If not provided, will find next available seed automatically."), + ) + ) + .subcommand(SubCommand::with_name("decrease-additional-validator-stake") + .about("Decrease stake from a validator in the same epoch. Must be signed by the pool staker.") + .arg( + Arg::with_name("pool") + .index(1) + .validator(is_pubkey) + .value_name("POOL_ADDRESS") + .takes_value(true) + .required(true) + .help("Stake pool address"), + ) + .arg( + Arg::with_name("vote_account") + .index(2) + .validator(is_pubkey) + .value_name("VOTE_ACCOUNT_ADDRESS") + .takes_value(true) + .required(true) + .help("Vote account for the validator to decrease stake from"), + ) + .arg( + Arg::with_name("amount") + .index(3) + .validator(is_amount) + .value_name("AMOUNT") + .takes_value(true) + .help("Amount in SOL to remove from the validator stake account using ephemeral accounts."), + ) + .arg( + Arg::with_name("ephemeral_stake_seed") + .long("ephemeral-seed") + .validator(is_parsable::) + .value_name("SEED") + .takes_value(true) + .help("Specific ephemeral stake seed to use. If not provided, will find next available seed automatically."), + ) + ) .subcommand(SubCommand::with_name("deposit-stake") .about("Deposit active stake account into the stake pool in exchange for pool tokens") .arg( @@ -3314,14 +3537,28 @@ fn main() { let vote_account = pubkey_of(arg_matches, "vote_account").unwrap(); let amount_str = arg_matches.value_of("amount").unwrap(); let lamports = native_token::sol_str_to_lamports(amount_str).unwrap(); - command_increase_validator_stake(&config, &stake_pool_address, &vote_account, lamports) + let allow_additional = arg_matches.is_present("allow_additional"); + command_increase_validator_stake( + &config, + &stake_pool_address, + &vote_account, + lamports, + allow_additional, + ) } ("decrease-validator-stake", Some(arg_matches)) => { let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); let vote_account = pubkey_of(arg_matches, "vote_account").unwrap(); let amount_str = arg_matches.value_of("amount").unwrap(); let lamports = native_token::sol_str_to_lamports(amount_str).unwrap(); - command_decrease_validator_stake(&config, &stake_pool_address, &vote_account, lamports) + let allow_additional = arg_matches.is_present("allow_additional"); + command_decrease_validator_stake( + &config, + &stake_pool_address, + &vote_account, + lamports, + allow_additional, + ) } ("set-preferred-validator", Some(arg_matches)) => { let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); @@ -3341,6 +3578,42 @@ fn main() { vote_account, ) } + ("increase-additional-validator-stake", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let vote_account = pubkey_of(arg_matches, "vote_account").unwrap(); + let amount_str = arg_matches.value_of("amount").unwrap(); + let lamports = native_token::sol_str_to_lamports(amount_str).unwrap(); + + let ephemeral_stake_seed = arg_matches + .value_of("ephemeral_stake_seed") + .map(|seed_str| seed_str.parse::().unwrap()); + + command_increase_additional_validator_stake( + &config, + &stake_pool_address, + &vote_account, + lamports, + ephemeral_stake_seed, + ) + } + ("decrease-additional-validator-stake", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let vote_account = pubkey_of(arg_matches, "vote_account").unwrap(); + let amount_str = arg_matches.value_of("amount").unwrap(); + let lamports = native_token::sol_str_to_lamports(amount_str).unwrap(); + + let ephemeral_stake_seed = arg_matches + .value_of("ephemeral_stake_seed") + .map(|seed_str| seed_str.parse::().unwrap()); + + command_decrease_additional_validator_stake( + &config, + &stake_pool_address, + &vote_account, + lamports, + ephemeral_stake_seed, + ) + } ("deposit-stake", Some(arg_matches)) => { let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); let stake_account = pubkey_of(arg_matches, "stake_account").unwrap(); From e85235cf3c6cf507f46e67cd86a56907db70b848 Mon Sep 17 00:00:00 2001 From: Gabriel Hicks <60371583+gabrielhicks@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:00:40 -0500 Subject: [PATCH 2/3] Clippy, refactor for u64::MAX, remove noisy logs --- clients/cli/src/client.rs | 2 +- clients/cli/src/main.rs | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/clients/cli/src/client.rs b/clients/cli/src/client.rs index 57de03b7..4491fc87 100644 --- a/clients/cli/src/client.rs +++ b/clients/cli/src/client.rs @@ -217,7 +217,7 @@ pub(crate) fn find_unused_ephemeral_stake_seed( Err(format!( "Could not find an unused ephemeral stake account after {} attempts. \ - Consider using a higher limit or cleaning up existing ephemeral accounts.", + All ephemeral seeds are in use. Wait for the next epoch for accounts to be cleaned up.", max_attempts ) .into()) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 6037def4..8b7801c6 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -1040,13 +1040,12 @@ fn command_increase_additional_validator_stake( Some(seed) => seed, None => { // Find an unused ephemeral stake seed - let seed = find_unused_ephemeral_stake_seed( + find_unused_ephemeral_stake_seed( &config.rpc_client, &config.stake_pool_program_id, stake_pool_address, - 1000, // Max attempts to find unused seed - )?; - seed + u64::MAX, // Check all possible ephemeral seeds + )? } }; if !config.no_update { @@ -1084,14 +1083,12 @@ fn command_decrease_additional_validator_stake( Some(seed) => seed, None => { // Find an unused ephemeral stake seed - let seed = find_unused_ephemeral_stake_seed( + find_unused_ephemeral_stake_seed( &config.rpc_client, &config.stake_pool_program_id, stake_pool_address, - 1000, // Max attempts to find unused seed - )?; - println!("Using ephemeral stake seed: {}", seed); - seed + u64::MAX, // Check all possible ephemeral seeds + )? } }; if !config.no_update { From 0a6316ddf85ffa4a5a74fe3d7d30b4df415613a6 Mon Sep 17 00:00:00 2001 From: Gabriel Hicks <60371583+gabrielhicks@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:01:31 -0500 Subject: [PATCH 3/3] Log removal --- clients/cli/src/main.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 8b7801c6..991eed28 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -903,7 +903,6 @@ fn command_increase_validator_stake( // Check if transient stake is in use if is_transient_stake_in_use(validator_stake_info) { if allow_additional_on_transient_busy { - println!("Transient stake account is in use. Attempting to use additional validator stake with ephemeral account..."); return command_increase_additional_validator_stake( config, stake_pool_address, @@ -964,7 +963,6 @@ fn command_decrease_validator_stake( // Check if transient stake is in use if is_transient_stake_in_use(validator_stake_info) { if allow_additional_on_transient_busy { - println!("Transient stake account is in use. Attempting to use additional validator stake with ephemeral account..."); return command_decrease_additional_validator_stake( config, stake_pool_address,