diff --git a/README.md b/README.md index 3567af82..c8df8f6b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ -# stake-pool -The SPL Stake Pool program and its clients +# stake-pool program + +Full documentation is available at https://spl.solana.com/stake-pool + +The command-line interface tool is available in the `./cli` directory. + +Javascript bindings are available in the `./js` directory. + +Python bindings are available in the `./py` directory. + +## Audit + +The repository [README](https://github.com/solana-labs/solana-program-library#audits) +contains information about program audits. diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml new file mode 100644 index 00000000..f6bb1e56 --- /dev/null +++ b/clients/cli/Cargo.toml @@ -0,0 +1,41 @@ +[package] +authors = ["Solana Labs Maintainers "] +description = "SPL-Stake-Pool Command-line Utility" +edition = "2021" +homepage = "https://spl.solana.com/stake-pool" +license = "Apache-2.0" +name = "spl-stake-pool-cli" +repository = "https://github.com/solana-labs/solana-program-library" +version = "2.0.0" + +[dependencies] +borsh = "1.5.3" +clap = "2.33.3" +serde = "1.0.215" +serde_derive = "1.0.130" +serde_json = "1.0.133" +solana-account-decoder = "2.1.0" +solana-clap-utils = "2.1.0" +solana-cli-config = "2.1.0" +solana-cli-output = "2.1.0" +solana-client = "2.1.0" +solana-logger = "2.1.0" +solana-program = "2.1.0" +solana-remote-wallet = "2.1.0" +solana-sdk = "2.1.0" +spl-associated-token-account = { version = "=6.0.0", path = "../../associated-token-account/program", features = [ + "no-entrypoint", +] } +spl-associated-token-account-client = { version = "=2.0.0", path = "../../associated-token-account/client" } +spl-stake-pool = { version = "=2.0.1", path = "../program", features = [ + "no-entrypoint", +] } +spl-token = { version = "=7.0", path = "../../token/program", features = [ + "no-entrypoint", +] } +bs58 = "0.5.1" +bincode = "1.3.1" + +[[bin]] +name = "spl-stake-pool" +path = "src/main.rs" diff --git a/clients/cli/README.md b/clients/cli/README.md new file mode 100644 index 00000000..b3ed9f3f --- /dev/null +++ b/clients/cli/README.md @@ -0,0 +1,7 @@ +# SPL Stake Pool program command-line utility + +A basic command-line for creating and using SPL Stake Pools. See https://spl.solana.com/stake-pool for more details. + +Under `./scripts`, there are helpful Bash scripts for setting up and running a +stake pool. More information at the +[stake pool quick start guide](https://spl.solana.com/stake-pool/quickstart). diff --git a/clients/cli/scripts/add-validators.sh b/clients/cli/scripts/add-validators.sh new file mode 100755 index 00000000..4bdcbdf1 --- /dev/null +++ b/clients/cli/scripts/add-validators.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# Script to add new validators to a stake pool, given the stake pool keyfile and +# a file listing validator vote account pubkeys + +cd "$(dirname "$0")" || exit +stake_pool_keyfile=$1 +validator_list=$2 # File containing validator vote account addresses, each will be added to the stake pool after creation + +add_validator_stakes () { + stake_pool=$1 + validator_list=$2 + while read -r validator + do + $spl_stake_pool add-validator "$stake_pool" "$validator" + done < "$validator_list" +} + +spl_stake_pool=spl-stake-pool +# Uncomment to use a local build +#spl_stake_pool=../../../target/debug/spl-stake-pool + +stake_pool_pubkey=$(solana-keygen pubkey "$stake_pool_keyfile") +echo "Adding validator stake accounts to the pool" +add_validator_stakes "$stake_pool_pubkey" "$validator_list" diff --git a/clients/cli/scripts/deposit.sh b/clients/cli/scripts/deposit.sh new file mode 100755 index 00000000..56cea561 --- /dev/null +++ b/clients/cli/scripts/deposit.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# Script to deposit stakes and SOL into a stake pool, given the stake pool keyfile +# and a path to a file containing a list of validator vote accounts + +cd "$(dirname "$0")" || exit +stake_pool_keyfile=$1 +validator_list=$2 +sol_amount=$3 + +create_keypair () { + if test ! -f "$1" + then + solana-keygen new --no-passphrase -s -o "$1" + fi +} + +create_user_stakes () { + validator_list=$1 + sol_amount=$2 + authority=$3 + while read -r validator + do + create_keypair "$keys_dir/stake_$validator".json + solana create-stake-account "$keys_dir/stake_$validator.json" "$sol_amount" --withdraw-authority "$authority" --stake-authority "$authority" + done < "$validator_list" +} + +delegate_user_stakes () { + validator_list=$1 + authority=$2 + while read -r validator + do + solana delegate-stake --force "$keys_dir/stake_$validator.json" "$validator" --stake-authority "$authority" + done < "$validator_list" +} + +deposit_stakes () { + stake_pool_pubkey=$1 + validator_list=$2 + authority=$3 + while read -r validator + do + stake=$(solana-keygen pubkey "$keys_dir/stake_$validator.json") + $spl_stake_pool deposit-stake "$stake_pool_pubkey" "$stake" --withdraw-authority "$authority" + done < "$validator_list" +} + +keys_dir=keys +stake_pool_pubkey=$(solana-keygen pubkey "$stake_pool_keyfile") + +spl_stake_pool=spl-stake-pool +# Uncomment to use a locally build CLI +#spl_stake_pool=../../../target/debug/spl-stake-pool + +echo "Setting up keys directory $keys_dir" +mkdir -p $keys_dir +authority=$keys_dir/authority.json +echo "Setting up authority for deposited stake accounts at $authority" +create_keypair $authority + +echo "Creating user stake accounts to deposit into the pool" +create_user_stakes "$validator_list" "$sol_amount" $authority +echo "Delegating user stakes so that deposit will work" +delegate_user_stakes "$validator_list" $authority + +echo "Waiting for stakes to activate, this may take awhile depending on the network!" +echo "If you are running on localnet with 32 slots per epoch, wait 12 seconds..." +sleep 12 +echo "Depositing stakes into stake pool" +deposit_stakes "$stake_pool_pubkey" "$validator_list" $authority diff --git a/clients/cli/scripts/rebalance.sh b/clients/cli/scripts/rebalance.sh new file mode 100755 index 00000000..3db44972 --- /dev/null +++ b/clients/cli/scripts/rebalance.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# Script to add a certain amount of SOL into a stake pool, given the stake pool +# keyfile and a path to a file containing a list of validator vote accounts + +cd "$(dirname "$0")" || exit +stake_pool_keyfile=$1 +validator_list=$2 +sol_amount=$3 + +spl_stake_pool=spl-stake-pool +# Uncomment to use a locally build CLI +#spl_stake_pool=../../../target/debug/spl-stake-pool + +increase_stakes () { + stake_pool_pubkey=$1 + validator_list=$2 + sol_amount=$3 + while read -r validator + do + $spl_stake_pool increase-validator-stake "$stake_pool_pubkey" "$validator" "$sol_amount" + done < "$validator_list" +} + +stake_pool_pubkey=$(solana-keygen pubkey "$stake_pool_keyfile") +echo "Increasing amount delegated to each validator in stake pool" +increase_stakes "$stake_pool_pubkey" "$validator_list" "$sol_amount" diff --git a/clients/cli/scripts/setup-stake-pool.sh b/clients/cli/scripts/setup-stake-pool.sh new file mode 100755 index 00000000..9bcb5b8b --- /dev/null +++ b/clients/cli/scripts/setup-stake-pool.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Script to setup a stake pool from scratch. Please modify the parameters to +# create a stake pool to your liking! + +cd "$(dirname "$0")" || exit +command_args=() +sol_amount=$1 + +################################################### +### MODIFY PARAMETERS BELOW THIS LINE FOR YOUR POOL +################################################### + +# Epoch fee, assessed as a percentage of rewards earned by the pool every epoch, +# represented as `numerator / denominator` +command_args+=( --epoch-fee-numerator 1 ) +command_args+=( --epoch-fee-denominator 100 ) + +# Withdrawal fee for SOL and stake accounts, represented as `numerator / denominator` +command_args+=( --withdrawal-fee-numerator 2 ) +command_args+=( --withdrawal-fee-denominator 100 ) + +# Deposit fee for SOL and stake accounts, represented as `numerator / denominator` +command_args+=( --deposit-fee-numerator 3 ) +command_args+=( --deposit-fee-denominator 100 ) + +command_args+=( --referral-fee 0 ) # Percentage of deposit fee that goes towards the referrer (a number between 0 and 100, inclusive) + +command_args+=( --max-validators 2350 ) # Maximum number of validators in the stake pool, 2350 is the current maximum possible + +# (Optional) Deposit authority, required to sign all deposits into the pool. +# Setting this variable makes the pool "private" or "restricted". +# Uncomment and set to a valid keypair if you want the pool to be restricted. +#command_args+=( --deposit-authority keys/authority.json ) + +################################################### +### MODIFY PARAMETERS ABOVE THIS LINE FOR YOUR POOL +################################################### + +keys_dir=keys +spl_stake_pool=spl-stake-pool +# Uncomment to use a local build +#spl_stake_pool=../../../target/debug/spl-stake-pool + +mkdir -p $keys_dir + +create_keypair () { + if test ! -f "$1" + then + solana-keygen new --no-passphrase -s -o "$1" + fi +} + +echo "Creating pool" +stake_pool_keyfile=$keys_dir/stake-pool.json +validator_list_keyfile=$keys_dir/validator-list.json +mint_keyfile=$keys_dir/mint.json +reserve_keyfile=$keys_dir/reserve.json +create_keypair $stake_pool_keyfile +create_keypair $validator_list_keyfile +create_keypair $mint_keyfile +create_keypair $reserve_keyfile + +set -ex +$spl_stake_pool \ + create-pool \ + "${command_args[@]}" \ + --pool-keypair "$stake_pool_keyfile" \ + --validator-list-keypair "$validator_list_keyfile" \ + --mint-keypair "$mint_keyfile" \ + --reserve-keypair "$reserve_keyfile" + +set +ex +stake_pool_pubkey=$(solana-keygen pubkey "$stake_pool_keyfile") +set -ex + +echo "Creating token metadata" +$spl_stake_pool \ + create-token-metadata \ + "$stake_pool_pubkey" \ + NAME \ + SYMBOL \ + URI + +echo "Depositing SOL into stake pool" +$spl_stake_pool deposit-sol "$stake_pool_pubkey" "$sol_amount" diff --git a/clients/cli/scripts/setup-test-validator.sh b/clients/cli/scripts/setup-test-validator.sh new file mode 100755 index 00000000..073e9f01 --- /dev/null +++ b/clients/cli/scripts/setup-test-validator.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Script to setup a local solana-test-validator with the stake pool program +# given a maximum number of validators and a file path to store the list of +# test validator vote accounts. + +cd "$(dirname "$0")" || exit +max_validators=$1 +validator_file=$2 + +create_keypair () { + if test ! -f "$1" + then + solana-keygen new --no-passphrase -s -o "$1" + fi +} + +setup_test_validator() { + solana-test-validator \ + --clone-upgradeable-program SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy \ + --clone-upgradeable-program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s \ + --url mainnet-beta \ + --slots-per-epoch 32 \ + --quiet --reset & + # Uncomment to use a locally built stake program + #solana-test-validator \ + # --bpf-program SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy ../../../target/deploy/spl_stake_pool.so \ + # --bpf-program metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s ../../program/tests/fixtures/mpl_token_metadata.so \ + # --slots-per-epoch 32 \ + # --quiet --reset & + pid=$! + solana config set --url http://127.0.0.1:8899 + solana config set --commitment confirmed + echo "waiting for solana-test-validator, pid: $pid" + sleep 15 +} + +create_vote_accounts () { + max_validators=$1 + validator_file=$2 + for number in $(seq 1 "$max_validators") + do + create_keypair "$keys_dir/identity_$number.json" + create_keypair "$keys_dir/vote_$number.json" + create_keypair "$keys_dir/withdrawer_$number.json" + solana create-vote-account "$keys_dir/vote_$number.json" "$keys_dir/identity_$number.json" "$keys_dir/withdrawer_$number.json" --commission 1 + vote_pubkey=$(solana-keygen pubkey "$keys_dir/vote_$number.json") + echo "$vote_pubkey" >> "$validator_file" + done +} + + +echo "Setup keys directory and clear old validator list file if found" +keys_dir=keys +mkdir -p $keys_dir +if test -f "$validator_file" +then + rm "$validator_file" +fi + +echo "Setting up local test validator" +setup_test_validator + +echo "Creating vote accounts, these accounts be added to the stake pool" +create_vote_accounts "$max_validators" "$validator_file" + +echo "Done adding $max_validators validator vote accounts, their pubkeys can be found in $validator_file" diff --git a/clients/cli/scripts/withdraw.sh b/clients/cli/scripts/withdraw.sh new file mode 100755 index 00000000..6ad2e327 --- /dev/null +++ b/clients/cli/scripts/withdraw.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +# Script to withdraw stakes and SOL from a stake pool, given the stake pool public key +# and a path to a file containing a list of validator vote accounts + +cd "$(dirname "$0")" || exit +stake_pool_keyfile=$1 +validator_list=$2 +withdraw_sol_amount=$3 + +create_keypair () { + if test ! -f "$1" + then + solana-keygen new --no-passphrase -s -o "$1" + fi +} + +create_stake_account () { + authority=$1 + while read -r validator + do + solana-keygen new --no-passphrase -o "$keys_dir/stake_account_$validator.json" + solana create-stake-account "$keys_dir/stake_account_$validator.json" 2 + solana delegate-stake --force "$keys_dir/stake_account_$validator.json" "$validator" + done < "$validator_list" +} + +withdraw_stakes () { + stake_pool_pubkey=$1 + validator_list=$2 + pool_amount=$3 + while read -r validator + do + $spl_stake_pool withdraw-stake "$stake_pool_pubkey" "$pool_amount" --vote-account "$validator" + done < "$validator_list" +} + +withdraw_stakes_to_stake_receiver () { + stake_pool_pubkey=$1 + validator_list=$2 + pool_amount=$3 + while read -r validator + do + stake_receiver=$(solana-keygen pubkey "$keys_dir/stake_account_$validator.json") + $spl_stake_pool withdraw-stake "$stake_pool_pubkey" "$pool_amount" --vote-account "$validator" --stake-receiver "$stake_receiver" + done < "$validator_list" +} + +spl_stake_pool=spl-stake-pool +# Uncomment to use a locally build CLI +# spl_stake_pool=../../../target/debug/spl-stake-pool + +stake_pool_pubkey=$(solana-keygen pubkey "$stake_pool_keyfile") +keys_dir=keys + +echo "Setting up keys directory $keys_dir" +mkdir -p $keys_dir +authority=$keys_dir/authority.json + +create_stake_account $authority +echo "Waiting for stakes to activate, this may take awhile depending on the network!" +echo "If you are running on localnet with 32 slots per epoch, wait 12 seconds..." +sleep 12 + +echo "Setting up authority for withdrawn stake accounts at $authority" +create_keypair $authority + +echo "Withdrawing stakes from stake pool" +withdraw_stakes "$stake_pool_pubkey" "$validator_list" "$withdraw_sol_amount" + +echo "Withdrawing stakes from stake pool to receive it in stake receiver account" +withdraw_stakes_to_stake_receiver "$stake_pool_pubkey" "$validator_list" "$withdraw_sol_amount" + +echo "Withdrawing SOL from stake pool to authority" +$spl_stake_pool withdraw-sol "$stake_pool_pubkey" $authority "$withdraw_sol_amount" diff --git a/clients/cli/src/client.rs b/clients/cli/src/client.rs new file mode 100644 index 00000000..5d3974a5 --- /dev/null +++ b/clients/cli/src/client.rs @@ -0,0 +1,184 @@ +use { + bincode::deserialize, + solana_account_decoder::UiAccountEncoding, + solana_client::{ + client_error::ClientError, + rpc_client::RpcClient, + rpc_config::{RpcAccountInfoConfig, RpcProgramAccountsConfig}, + rpc_filter::{Memcmp, RpcFilterType}, + }, + solana_program::{ + borsh1::try_from_slice_unchecked, hash::Hash, instruction::Instruction, message::Message, + program_pack::Pack, pubkey::Pubkey, stake, + }, + solana_sdk::{compute_budget::ComputeBudgetInstruction, transaction::Transaction}, + spl_stake_pool::{ + find_withdraw_authority_program_address, + state::{StakePool, ValidatorList}, + }, + std::collections::HashSet, +}; + +pub(crate) type Error = Box; + +pub fn get_stake_pool( + rpc_client: &RpcClient, + stake_pool_address: &Pubkey, +) -> Result { + let account_data = rpc_client.get_account_data(stake_pool_address)?; + let stake_pool = try_from_slice_unchecked::(account_data.as_slice()) + .map_err(|err| format!("Invalid stake pool {}: {}", stake_pool_address, err))?; + Ok(stake_pool) +} + +pub fn get_validator_list( + rpc_client: &RpcClient, + validator_list_address: &Pubkey, +) -> Result { + let account_data = rpc_client.get_account_data(validator_list_address)?; + let validator_list = try_from_slice_unchecked::(account_data.as_slice()) + .map_err(|err| format!("Invalid validator list {}: {}", validator_list_address, err))?; + Ok(validator_list) +} + +pub fn get_token_account( + rpc_client: &RpcClient, + token_account_address: &Pubkey, + expected_token_mint: &Pubkey, +) -> Result { + let account_data = rpc_client.get_account_data(token_account_address)?; + let token_account = spl_token::state::Account::unpack_from_slice(account_data.as_slice()) + .map_err(|err| format!("Invalid token account {}: {}", token_account_address, err))?; + + if token_account.mint != *expected_token_mint { + Err(format!( + "Invalid token mint for {}, expected mint is {}", + token_account_address, expected_token_mint + ) + .into()) + } else { + Ok(token_account) + } +} + +pub fn get_token_mint( + rpc_client: &RpcClient, + token_mint_address: &Pubkey, +) -> Result { + let account_data = rpc_client.get_account_data(token_mint_address)?; + let token_mint = spl_token::state::Mint::unpack_from_slice(account_data.as_slice()) + .map_err(|err| format!("Invalid token mint {}: {}", token_mint_address, err))?; + + Ok(token_mint) +} + +pub(crate) fn get_stake_state( + rpc_client: &RpcClient, + stake_address: &Pubkey, +) -> Result { + let account_data = rpc_client.get_account_data(stake_address)?; + let stake_state = deserialize(account_data.as_slice()) + .map_err(|err| format!("Invalid stake account {}: {}", stake_address, err))?; + Ok(stake_state) +} + +pub(crate) fn get_stake_pools( + rpc_client: &RpcClient, +) -> Result, ClientError> { + rpc_client + .get_program_accounts_with_config( + &spl_stake_pool::id(), + RpcProgramAccountsConfig { + // 0 is the account type + filters: Some(vec![RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + 0, + vec![1], + ))]), + account_config: RpcAccountInfoConfig { + encoding: Some(UiAccountEncoding::Base64), + ..RpcAccountInfoConfig::default() + }, + ..RpcProgramAccountsConfig::default() + }, + ) + .map(|accounts| { + accounts + .into_iter() + .filter_map(|(address, account)| { + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), &address).0; + match try_from_slice_unchecked::(account.data.as_slice()) { + Ok(stake_pool) => { + get_validator_list(rpc_client, &stake_pool.validator_list) + .map(|validator_list| { + (address, stake_pool, validator_list, pool_withdraw_authority) + }) + .ok() + } + Err(err) => { + eprintln!("Invalid stake pool data for {}: {}", address, err); + None + } + } + }) + .collect() + }) +} + +pub(crate) fn get_all_stake( + rpc_client: &RpcClient, + authorized_staker: &Pubkey, +) -> Result, ClientError> { + let all_stake_accounts = rpc_client.get_program_accounts_with_config( + &stake::program::id(), + RpcProgramAccountsConfig { + filters: Some(vec![ + // Filter by `Meta::authorized::staker`, which begins at byte offset 12 + RpcFilterType::Memcmp(Memcmp::new_base58_encoded(12, authorized_staker.as_ref())), + ]), + account_config: RpcAccountInfoConfig { + encoding: Some(solana_account_decoder::UiAccountEncoding::Base64), + commitment: Some(rpc_client.commitment()), + ..RpcAccountInfoConfig::default() + }, + ..RpcProgramAccountsConfig::default() + }, + )?; + + Ok(all_stake_accounts + .into_iter() + .map(|(address, _)| address) + .collect()) +} + +/// Helper function to add a compute unit limit instruction to a given set +/// of instructions +pub(crate) fn add_compute_unit_limit_from_simulation( + rpc_client: &RpcClient, + instructions: &mut Vec, + payer: &Pubkey, + blockhash: &Hash, +) -> Result<(), Error> { + // add a max compute unit limit instruction for the simulation + const MAX_COMPUTE_UNIT_LIMIT: u32 = 1_400_000; + instructions.push(ComputeBudgetInstruction::set_compute_unit_limit( + MAX_COMPUTE_UNIT_LIMIT, + )); + + let transaction = Transaction::new_unsigned(Message::new_with_blockhash( + instructions, + Some(payer), + blockhash, + )); + let simulation_result = rpc_client.simulate_transaction(&transaction)?.value; + let units_consumed = simulation_result + .units_consumed + .ok_or("No units consumed on simulation")?; + // Overwrite the compute unit limit instruction with the actual units consumed + let compute_unit_limit = u32::try_from(units_consumed)?; + instructions + .last_mut() + .expect("Compute budget instruction was added earlier") + .data = ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit).data; + Ok(()) +} diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs new file mode 100644 index 00000000..e56990f1 --- /dev/null +++ b/clients/cli/src/main.rs @@ -0,0 +1,3479 @@ +#![allow(clippy::arithmetic_side_effects)] +mod client; +mod output; + +use { + crate::{ + client::*, + output::{CliStakePool, CliStakePoolDetails, CliStakePoolStakeAccountInfo, CliStakePools}, + }, + bincode::deserialize, + clap::{ + crate_description, crate_name, crate_version, value_t, value_t_or_exit, App, AppSettings, + Arg, ArgGroup, ArgMatches, SubCommand, + }, + solana_clap_utils::{ + compute_unit_price::{compute_unit_price_arg, COMPUTE_UNIT_PRICE_ARG}, + input_parsers::{keypair_of, pubkey_of}, + input_validators::{ + is_amount, is_keypair_or_ask_keyword, is_parsable, is_pubkey, is_url, + is_valid_percentage, is_valid_pubkey, is_valid_signer, + }, + keypair::{signer_from_path_with_config, SignerFromPathConfig}, + ArgConstant, + }, + solana_cli_output::OutputFormat, + solana_client::rpc_client::RpcClient, + solana_program::{ + borsh1::{get_instance_packed_len, get_packed_len}, + instruction::Instruction, + program_pack::Pack, + pubkey::Pubkey, + stake, + }, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_sdk::{ + commitment_config::CommitmentConfig, + compute_budget::ComputeBudgetInstruction, + hash::Hash, + message::Message, + native_token::{self, Sol}, + signature::{Keypair, Signer}, + signers::Signers, + system_instruction, + transaction::Transaction, + }, + spl_associated_token_account::instruction::create_associated_token_account, + spl_associated_token_account_client::address::get_associated_token_address, + spl_stake_pool::{ + self, find_stake_program_address, find_transient_stake_program_address, + find_withdraw_authority_program_address, + instruction::{FundingType, PreferredValidatorType}, + minimum_delegation, + state::{Fee, FeeType, StakePool, ValidatorList, ValidatorStakeInfo}, + MINIMUM_RESERVE_LAMPORTS, + }, + std::{cmp::Ordering, num::NonZeroU32, process::exit, rc::Rc}, +}; + +pub(crate) struct Config { + rpc_client: RpcClient, + verbose: bool, + output_format: OutputFormat, + manager: Box, + staker: Box, + funding_authority: Option>, + token_owner: Box, + fee_payer: Box, + dry_run: bool, + no_update: bool, + compute_unit_price: Option, + compute_unit_limit: ComputeUnitLimit, +} + +type CommandResult = Result<(), Error>; + +const STAKE_STATE_LEN: usize = 200; + +macro_rules! unique_signers { + ($vec:ident) => { + $vec.sort_by_key(|l| l.pubkey()); + $vec.dedup(); + }; +} + +fn check_fee_payer_balance(config: &Config, required_balance: u64) -> Result<(), Error> { + let balance = config.rpc_client.get_balance(&config.fee_payer.pubkey())?; + if balance < required_balance { + Err(format!( + "Fee payer, {}, has insufficient balance: {} required, {} available", + config.fee_payer.pubkey(), + Sol(required_balance), + Sol(balance) + ) + .into()) + } else { + Ok(()) + } +} + +const FEES_REFERENCE: &str = "Consider setting a minimal fee. \ + See https://spl.solana.com/stake-pool/fees for more \ + information about fees and best practices. If you are \ + aware of the possible risks of a stake pool with no fees, \ + you may force pool creation with the --unsafe-fees flag."; + +enum ComputeUnitLimit { + Default, + Static(u32), + Simulated, +} +const COMPUTE_UNIT_LIMIT_ARG: ArgConstant<'static> = ArgConstant { + name: "compute_unit_limit", + long: "--with-compute-unit-limit", + help: "Set compute unit limit for transaction, in compute units; also accepts \ + keyword DEFAULT to use the default compute unit limit, which is 200k per \ + top-level instruction, with a maximum of 1.4 million. \ + If nothing is set, transactions are simulated prior to sending, and the \ + compute units consumed are set as the limit. This may may fail if accounts \ + are modified by another transaction between simulation and execution.", +}; +fn is_compute_unit_limit_or_simulated(string: T) -> Result<(), String> +where + T: AsRef + std::fmt::Display, +{ + if string.as_ref().parse::().is_ok() || string.as_ref() == "DEFAULT" { + Ok(()) + } else { + Err(format!( + "Unable to parse input compute unit limit as integer or DEFAULT, provided: {string}" + )) + } +} +fn parse_compute_unit_limit(string: T) -> Result +where + T: AsRef + std::fmt::Display, +{ + match string.as_ref().parse::() { + Ok(compute_unit_limit) => Ok(ComputeUnitLimit::Static(compute_unit_limit)), + Err(_) if string.as_ref() == "DEFAULT" => Ok(ComputeUnitLimit::Default), + _ => Err(format!( + "Unable to parse compute unit limit, provided: {string}" + )), + } +} + +fn check_stake_pool_fees( + epoch_fee: &Fee, + withdrawal_fee: &Fee, + deposit_fee: &Fee, +) -> Result<(), Error> { + if epoch_fee.numerator == 0 || epoch_fee.denominator == 0 { + return Err(format!("Epoch fee should not be 0. {}", FEES_REFERENCE,).into()); + } + let is_withdrawal_fee_zero = withdrawal_fee.numerator == 0 || withdrawal_fee.denominator == 0; + let is_deposit_fee_zero = deposit_fee.numerator == 0 || deposit_fee.denominator == 0; + if is_withdrawal_fee_zero && is_deposit_fee_zero { + return Err(format!( + "Withdrawal and deposit fee should not both be 0. {}", + FEES_REFERENCE, + ) + .into()); + } + Ok(()) +} + +fn get_signer( + matches: &ArgMatches<'_>, + keypair_name: &str, + keypair_path: &str, + wallet_manager: &mut Option>, + signer_from_path_config: SignerFromPathConfig, +) -> Box { + signer_from_path_with_config( + matches, + matches.value_of(keypair_name).unwrap_or(keypair_path), + keypair_name, + wallet_manager, + &signer_from_path_config, + ) + .unwrap_or_else(|e| { + eprintln!("error: {}", e); + exit(1); + }) +} + +fn get_latest_blockhash(client: &RpcClient) -> Result { + Ok(client + .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())? + .0) +} + +fn send_transaction_no_wait( + config: &Config, + transaction: Transaction, +) -> solana_client::client_error::Result<()> { + if config.dry_run { + let result = config.rpc_client.simulate_transaction(&transaction)?; + println!("Simulate result: {:?}", result); + } else { + let signature = config.rpc_client.send_transaction(&transaction)?; + println!("Signature: {}", signature); + } + Ok(()) +} + +fn send_transaction( + config: &Config, + transaction: Transaction, +) -> solana_client::client_error::Result<()> { + if config.dry_run { + let result = config.rpc_client.simulate_transaction(&transaction)?; + println!("Simulate result: {:?}", result); + } else { + let signature = config + .rpc_client + .send_and_confirm_transaction_with_spinner(&transaction)?; + println!("Signature: {}", signature); + } + Ok(()) +} + +fn checked_transaction_with_signers_and_additional_fee( + config: &Config, + instructions: &[Instruction], + signers: &T, + additional_fee: u64, +) -> Result { + let recent_blockhash = get_latest_blockhash(&config.rpc_client)?; + let mut instructions = instructions.to_vec(); + if let Some(compute_unit_price) = config.compute_unit_price { + instructions.push(ComputeBudgetInstruction::set_compute_unit_price( + compute_unit_price, + )); + } + match config.compute_unit_limit { + ComputeUnitLimit::Default => {} + ComputeUnitLimit::Static(compute_unit_limit) => { + instructions.push(ComputeBudgetInstruction::set_compute_unit_limit( + compute_unit_limit, + )); + } + ComputeUnitLimit::Simulated => { + add_compute_unit_limit_from_simulation( + &config.rpc_client, + &mut instructions, + &config.fee_payer.pubkey(), + &recent_blockhash, + )?; + } + } + let message = Message::new_with_blockhash( + &instructions, + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + check_fee_payer_balance( + config, + additional_fee.saturating_add(config.rpc_client.get_fee_for_message(&message)?), + )?; + let transaction = Transaction::new(signers, message, recent_blockhash); + Ok(transaction) +} + +fn checked_transaction_with_signers( + config: &Config, + instructions: &[Instruction], + signers: &T, +) -> Result { + checked_transaction_with_signers_and_additional_fee(config, instructions, signers, 0) +} + +fn new_stake_account( + fee_payer: &Pubkey, + instructions: &mut Vec, + lamports: u64, +) -> Keypair { + // Account for tokens not specified, creating one + let stake_receiver_keypair = Keypair::new(); + let stake_receiver_pubkey = stake_receiver_keypair.pubkey(); + println!( + "Creating account to receive stake {}", + stake_receiver_pubkey + ); + + instructions.push( + // Creating new account + system_instruction::create_account( + fee_payer, + &stake_receiver_pubkey, + lamports, + STAKE_STATE_LEN as u64, + &stake::program::id(), + ), + ); + + stake_receiver_keypair +} + +fn setup_reserve_stake_account( + config: &Config, + reserve_keypair: &Keypair, + reserve_stake_balance: u64, + withdraw_authority: &Pubkey, +) -> CommandResult { + let reserve_account_info = config.rpc_client.get_account(&reserve_keypair.pubkey()); + if let Ok(account) = reserve_account_info { + if account.owner == stake::program::id() { + if account.data.iter().any(|&x| x != 0) { + println!( + "Reserve stake account {} already exists and is initialized", + reserve_keypair.pubkey() + ); + return Ok(()); + } else { + let instructions = vec![stake::instruction::initialize( + &reserve_keypair.pubkey(), + &stake::state::Authorized { + staker: *withdraw_authority, + withdrawer: *withdraw_authority, + }, + &stake::state::Lockup::default(), + )]; + let signers = vec![config.fee_payer.as_ref()]; + let transaction = + checked_transaction_with_signers(config, &instructions, &signers)?; + println!( + "Initializing existing reserve stake account {}", + reserve_keypair.pubkey() + ); + send_transaction(config, transaction)?; + return Ok(()); + } + } + } + + let instructions = vec![ + system_instruction::create_account( + &config.fee_payer.pubkey(), + &reserve_keypair.pubkey(), + reserve_stake_balance, + STAKE_STATE_LEN as u64, + &stake::program::id(), + ), + stake::instruction::initialize( + &reserve_keypair.pubkey(), + &stake::state::Authorized { + staker: *withdraw_authority, + withdrawer: *withdraw_authority, + }, + &stake::state::Lockup::default(), + ), + ]; + + let signers = vec![config.fee_payer.as_ref(), reserve_keypair]; + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + + println!( + "Creating and initializing reserve stake account {}", + reserve_keypair.pubkey() + ); + send_transaction(config, transaction)?; + Ok(()) +} + +fn setup_mint_account( + config: &Config, + mint_keypair: &Keypair, + mint_account_balance: u64, + withdraw_authority: &Pubkey, + default_decimals: u8, +) -> CommandResult { + let mint_account_info = config.rpc_client.get_account(&mint_keypair.pubkey()); + if let Ok(account) = mint_account_info { + if account.owner == spl_token::id() { + if account.data.iter().any(|&x| x != 0) { + println!( + "Mint account {} already exists and is initialized", + mint_keypair.pubkey() + ); + return Ok(()); + } else { + let instructions = vec![spl_token::instruction::initialize_mint( + &spl_token::id(), + &mint_keypair.pubkey(), + withdraw_authority, + None, + default_decimals, + )?]; + let signers = vec![config.fee_payer.as_ref()]; + let transaction = + checked_transaction_with_signers(config, &instructions, &signers)?; + println!( + "Initializing existing mint account {}", + mint_keypair.pubkey() + ); + send_transaction(config, transaction)?; + return Ok(()); + } + } + } + + let instructions = vec![ + system_instruction::create_account( + &config.fee_payer.pubkey(), + &mint_keypair.pubkey(), + mint_account_balance, + spl_token::state::Mint::LEN as u64, + &spl_token::id(), + ), + spl_token::instruction::initialize_mint( + &spl_token::id(), + &mint_keypair.pubkey(), + withdraw_authority, + None, + default_decimals, + )?, + ]; + + let signers = vec![config.fee_payer.as_ref(), mint_keypair]; + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + + println!( + "Creating and initializing mint account {}", + mint_keypair.pubkey() + ); + send_transaction(config, transaction)?; + Ok(()) +} + +fn setup_pool_fee_account( + config: &Config, + mint_pubkey: &Pubkey, + total_rent_free_balances: &mut u64, +) -> CommandResult { + let pool_fee_account = get_associated_token_address(&config.manager.pubkey(), mint_pubkey); + let pool_fee_account_info = config.rpc_client.get_account(&pool_fee_account); + if let Ok(account) = pool_fee_account_info { + if account.owner == spl_token::id() { + println!("Pool fee account {} already exists", pool_fee_account); + return Ok(()); + } + } + // Create pool fee account + let mut instructions = vec![]; + add_associated_token_account( + config, + mint_pubkey, + &config.manager.pubkey(), + &mut instructions, + total_rent_free_balances, + ); + + println!("Creating pool fee collection account {}", pool_fee_account); + + let signers = vec![config.fee_payer.as_ref()]; + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + + send_transaction(config, transaction)?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn setup_and_initialize_validator_list_with_stake_pool( + config: &Config, + stake_pool_keypair: &Keypair, + validator_list_keypair: &Keypair, + reserve_keypair: &Keypair, + mint_keypair: &Keypair, + pool_fee_account: &Pubkey, + deposit_authority: Option, + epoch_fee: Fee, + withdrawal_fee: Fee, + deposit_fee: Fee, + referral_fee: u8, + max_validators: u32, + withdraw_authority: &Pubkey, + validator_list_balance: u64, + validator_list_size: usize, +) -> CommandResult { + let stake_pool_account_info = config.rpc_client.get_account(&stake_pool_keypair.pubkey()); + let validator_list_account_info = config + .rpc_client + .get_account(&validator_list_keypair.pubkey()); + + let stake_pool_account_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(get_packed_len::())?; + + let mut instructions = vec![]; + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + + if let Ok(account) = validator_list_account_info { + if account.owner == spl_stake_pool::id() { + if account.data.iter().all(|&x| x == 0) { + println!( + "Validator list account {} already exists and is ready to be initialized", + validator_list_keypair.pubkey() + ); + } else { + println!( + "Validator list account {} already exists and is initialized", + validator_list_keypair.pubkey() + ); + return Ok(()); + } + } + } else { + instructions.push(system_instruction::create_account( + &config.fee_payer.pubkey(), + &validator_list_keypair.pubkey(), + validator_list_balance, + validator_list_size as u64, + &spl_stake_pool::id(), + )); + signers.push(validator_list_keypair); + } + + if let Ok(account) = stake_pool_account_info { + if account.owner == spl_stake_pool::id() { + if account.data.iter().all(|&x| x == 0) { + println!( + "Stake pool account {} already exists but is not initialized", + stake_pool_keypair.pubkey() + ); + } else { + println!( + "Stake pool account {} already exists and is initialized", + stake_pool_keypair.pubkey() + ); + return Ok(()); + } + } + } else { + instructions.push(system_instruction::create_account( + &config.fee_payer.pubkey(), + &stake_pool_keypair.pubkey(), + stake_pool_account_lamports, + get_packed_len::() as u64, + &spl_stake_pool::id(), + )); + } + instructions.push(spl_stake_pool::instruction::initialize( + &spl_stake_pool::id(), + &stake_pool_keypair.pubkey(), + &config.manager.pubkey(), + &config.staker.pubkey(), + withdraw_authority, + &validator_list_keypair.pubkey(), + &reserve_keypair.pubkey(), + &mint_keypair.pubkey(), + pool_fee_account, + &spl_token::id(), + deposit_authority.as_ref().map(|x| x.pubkey()), + epoch_fee, + withdrawal_fee, + deposit_fee, + referral_fee, + max_validators, + )); + signers.push(stake_pool_keypair); + + if let Some(ref deposit_auth) = deposit_authority { + signers.push(deposit_auth); + println!( + "Deposits will be restricted to {} only, this can be changed using the set-funding-authority command.", + deposit_auth.pubkey() + ); + } + + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + + println!( + "Setting up and initializing stake pool account {} with validator list {}", + stake_pool_keypair.pubkey(), + validator_list_keypair.pubkey() + ); + send_transaction(config, transaction)?; + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn command_create_pool( + config: &Config, + deposit_authority: Option, + epoch_fee: Fee, + withdrawal_fee: Fee, + deposit_fee: Fee, + referral_fee: u8, + max_validators: u32, + stake_pool_keypair: Option, + validator_list_keypair: Option, + mint_keypair: Option, + reserve_keypair: Option, + unsafe_fees: bool, +) -> CommandResult { + if !unsafe_fees { + check_stake_pool_fees(&epoch_fee, &withdrawal_fee, &deposit_fee)?; + } + + let reserve_keypair = reserve_keypair.unwrap_or_else(Keypair::new); + let mint_keypair = mint_keypair.unwrap_or_else(Keypair::new); + let stake_pool_keypair = stake_pool_keypair.unwrap_or_else(Keypair::new); + let validator_list_keypair = validator_list_keypair.unwrap_or_else(Keypair::new); + + let reserve_stake_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(STAKE_STATE_LEN)? + + MINIMUM_RESERVE_LAMPORTS; + let mint_account_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(spl_token::state::Mint::LEN)?; + let pool_fee_account_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN)?; + let stake_pool_account_lamports = config + .rpc_client + .get_minimum_balance_for_rent_exemption(get_packed_len::())?; + let empty_validator_list = ValidatorList::new(max_validators); + let validator_list_size = get_instance_packed_len(&empty_validator_list)?; + let validator_list_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(validator_list_size)?; + let mut total_rent_free_balances = reserve_stake_balance + + mint_account_balance + + pool_fee_account_balance + + stake_pool_account_lamports + + validator_list_balance; + + let default_decimals = spl_token::native_mint::DECIMALS; + + // Calculate withdraw authority used for minting pool tokens + let (withdraw_authority, _) = find_withdraw_authority_program_address( + &spl_stake_pool::id(), + &stake_pool_keypair.pubkey(), + ); + + if config.verbose { + println!("Stake pool withdraw authority {}", withdraw_authority); + } + + setup_reserve_stake_account( + config, + &reserve_keypair, + reserve_stake_balance, + &withdraw_authority, + )?; + setup_mint_account( + config, + &mint_keypair, + mint_account_balance, + &withdraw_authority, + default_decimals, + )?; + setup_pool_fee_account( + config, + &mint_keypair.pubkey(), + &mut total_rent_free_balances, + )?; + + let pool_fee_account = + get_associated_token_address(&config.manager.pubkey(), &mint_keypair.pubkey()); + + setup_and_initialize_validator_list_with_stake_pool( + config, + &stake_pool_keypair, + &validator_list_keypair, + &reserve_keypair, + &mint_keypair, + &pool_fee_account, + deposit_authority, + epoch_fee, + withdrawal_fee, + deposit_fee, + referral_fee, + max_validators, + &withdraw_authority, + validator_list_balance, + validator_list_size, + )?; + + Ok(()) +} + +fn create_token_metadata( + config: &Config, + stake_pool_address: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> CommandResult { + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + let instructions = vec![spl_stake_pool::instruction::create_token_metadata( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.manager, + &stake_pool.pool_mint, + &config.fee_payer.pubkey(), + name, + symbol, + uri, + )]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn update_token_metadata( + config: &Config, + stake_pool_address: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> CommandResult { + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + let instructions = vec![spl_stake_pool::instruction::update_token_metadata( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.manager, + &stake_pool.pool_mint, + name, + symbol, + uri, + )]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_vsa_add( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, +) -> CommandResult { + 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)?; + if validator_list.contains(vote_account) { + println!( + "Stake pool already contains validator {}, ignoring", + vote_account + ); + return Ok(()); + } + + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + + // iterate until a free account is found + let (stake_account_address, validator_seed) = { + let mut i = 0; + loop { + let seed = NonZeroU32::new(i); + let (address, _) = find_stake_program_address( + &spl_stake_pool::id(), + vote_account, + stake_pool_address, + seed, + ); + let maybe_account = config + .rpc_client + .get_account_with_commitment( + &stake_pool.reserve_stake, + config.rpc_client.commitment(), + )? + .value; + if maybe_account.is_some() { + break (address, seed); + } + i += 1; + } + }; + println!( + "Adding stake account {}, delegated to {}", + stake_account_address, vote_account + ); + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[ + spl_stake_pool::instruction::add_validator_to_pool_with_vote( + &spl_stake_pool::id(), + &stake_pool, + stake_pool_address, + vote_account, + validator_seed, + ), + ], + &signers, + )?; + + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_vsa_remove( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, +) -> CommandResult { + 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 validator_stake_info = validator_list + .find(vote_account) + .ok_or("Vote account not found in validator list")?; + + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + let (stake_account_address, _) = find_stake_program_address( + &spl_stake_pool::id(), + vote_account, + stake_pool_address, + validator_seed, + ); + println!( + "Removing stake account {}, delegated to {}", + stake_account_address, vote_account + ); + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + let instructions = vec![ + // Create new validator stake account address + spl_stake_pool::instruction::remove_validator_from_pool_with_vote( + &spl_stake_pool::id(), + &stake_pool, + stake_pool_address, + vote_account, + validator_seed, + validator_stake_info.transient_seed_suffix.into(), + ), + ]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_increase_validator_stake( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, + amount: f64, +) -> CommandResult { + let lamports = native_token::sol_to_lamports(amount); + 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 validator_stake_info = validator_list + .find(vote_account) + .ok_or("Vote account not found in validator list")?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[ + spl_stake_pool::instruction::increase_validator_stake_with_vote( + &spl_stake_pool::id(), + &stake_pool, + stake_pool_address, + vote_account, + lamports, + validator_seed, + validator_stake_info.transient_seed_suffix.into(), + ), + ], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_decrease_validator_stake( + config: &Config, + stake_pool_address: &Pubkey, + vote_account: &Pubkey, + amount: f64, +) -> CommandResult { + let lamports = native_token::sol_to_lamports(amount); + 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 validator_stake_info = validator_list + .find(vote_account) + .ok_or("Vote account not found in validator list")?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[ + spl_stake_pool::instruction::decrease_validator_stake_with_vote( + &spl_stake_pool::id(), + &stake_pool, + stake_pool_address, + vote_account, + lamports, + validator_seed, + validator_stake_info.transient_seed_suffix.into(), + ), + ], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_set_preferred_validator( + config: &Config, + stake_pool_address: &Pubkey, + preferred_type: PreferredValidatorType, + vote_address: Option, +) -> CommandResult { + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + let mut signers = vec![config.fee_payer.as_ref(), config.staker.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[spl_stake_pool::instruction::set_preferred_validator( + &spl_stake_pool::id(), + stake_pool_address, + &config.staker.pubkey(), + &stake_pool.validator_list, + preferred_type, + vote_address, + )], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn add_associated_token_account( + config: &Config, + mint: &Pubkey, + owner: &Pubkey, + instructions: &mut Vec, + rent_free_balances: &mut u64, +) -> Pubkey { + // Account for tokens not specified, creating one + let account = get_associated_token_address(owner, mint); + if get_token_account(&config.rpc_client, &account, mint).is_err() { + println!("Creating associated token account {} to receive stake pool tokens of mint {}, owned by {}", account, mint, owner); + + let min_account_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN) + .unwrap(); + + instructions.push(create_associated_token_account( + &config.fee_payer.pubkey(), + owner, + mint, + &spl_token::id(), + )); + + *rent_free_balances += min_account_balance; + } else { + println!("Using existing associated token account {} to receive stake pool tokens of mint {}, owned by {}", account, mint, owner); + } + + account +} + +fn command_deposit_stake( + config: &Config, + stake_pool_address: &Pubkey, + stake: &Pubkey, + withdraw_authority: Box, + pool_token_receiver_account: &Option, + referrer_token_account: &Option, +) -> CommandResult { + 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 stake_state = get_stake_state(&config.rpc_client, stake)?; + + if config.verbose { + println!("Depositing stake account {:?}", stake_state); + } + let vote_account = match stake_state { + stake::state::StakeStateV2::Stake(_, stake, _) => Ok(stake.delegation.voter_pubkey), + _ => Err("Wrong stake account state, must be delegated to validator"), + }?; + + // Check if this vote account has staking account in the pool + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + let validator_stake_info = validator_list + .find(&vote_account) + .ok_or("Vote account not found in the stake pool")?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + + // Calculate validator stake account address linked to the pool + let (validator_stake_account, _) = find_stake_program_address( + &spl_stake_pool::id(), + &vote_account, + stake_pool_address, + validator_seed, + ); + + let validator_stake_state = get_stake_state(&config.rpc_client, &validator_stake_account)?; + println!( + "Depositing stake {} into stake pool account {}", + stake, validator_stake_account + ); + if config.verbose { + println!("{:?}", validator_stake_state); + } + + let mut instructions: Vec = vec![]; + let mut signers = vec![config.fee_payer.as_ref(), withdraw_authority.as_ref()]; + + let mut total_rent_free_balances: u64 = 0; + + // Create token account if not specified + let pool_token_receiver_account = + pool_token_receiver_account.unwrap_or(add_associated_token_account( + config, + &stake_pool.pool_mint, + &config.token_owner.pubkey(), + &mut instructions, + &mut total_rent_free_balances, + )); + + let referrer_token_account = referrer_token_account.unwrap_or(pool_token_receiver_account); + + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + + let mut deposit_instructions = + if let Some(stake_deposit_authority) = config.funding_authority.as_ref() { + signers.push(stake_deposit_authority.as_ref()); + if stake_deposit_authority.pubkey() != stake_pool.stake_deposit_authority { + let error = format!( + "Invalid deposit authority specified, expected {}, received {}", + stake_pool.stake_deposit_authority, + stake_deposit_authority.pubkey() + ); + return Err(error.into()); + } + + spl_stake_pool::instruction::deposit_stake_with_authority( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.validator_list, + &stake_deposit_authority.pubkey(), + &pool_withdraw_authority, + stake, + &withdraw_authority.pubkey(), + &validator_stake_account, + &stake_pool.reserve_stake, + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + ) + } else { + spl_stake_pool::instruction::deposit_stake( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.validator_list, + &pool_withdraw_authority, + stake, + &withdraw_authority.pubkey(), + &validator_stake_account, + &stake_pool.reserve_stake, + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + ) + }; + + instructions.append(&mut deposit_instructions); + + unique_signers!(signers); + let transaction = checked_transaction_with_signers_and_additional_fee( + config, + &instructions, + &signers, + total_rent_free_balances, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_deposit_all_stake( + config: &Config, + stake_pool_address: &Pubkey, + stake_authority: &Pubkey, + withdraw_authority: Box, + pool_token_receiver_account: &Option, + referrer_token_account: &Option, +) -> CommandResult { + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + + let stake_addresses = get_all_stake(&config.rpc_client, stake_authority)?; + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + + // Create token account if not specified + let mut total_rent_free_balances = 0; + let mut create_token_account_instructions = vec![]; + let pool_token_receiver_account = + pool_token_receiver_account.unwrap_or(add_associated_token_account( + config, + &stake_pool.pool_mint, + &config.token_owner.pubkey(), + &mut create_token_account_instructions, + &mut total_rent_free_balances, + )); + if !create_token_account_instructions.is_empty() { + let transaction = checked_transaction_with_signers_and_additional_fee( + config, + &create_token_account_instructions, + &[config.fee_payer.as_ref()], + total_rent_free_balances, + )?; + send_transaction(config, transaction)?; + } + + let referrer_token_account = referrer_token_account.unwrap_or(pool_token_receiver_account); + + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + let mut signers = if let Some(stake_deposit_authority) = config.funding_authority.as_ref() { + if stake_deposit_authority.pubkey() != stake_pool.stake_deposit_authority { + let error = format!( + "Invalid deposit authority specified, expected {}, received {}", + stake_pool.stake_deposit_authority, + stake_deposit_authority.pubkey() + ); + return Err(error.into()); + } + + vec![ + config.fee_payer.as_ref(), + withdraw_authority.as_ref(), + stake_deposit_authority.as_ref(), + ] + } else { + vec![config.fee_payer.as_ref(), withdraw_authority.as_ref()] + }; + unique_signers!(signers); + + for stake_address in stake_addresses { + let stake_state = get_stake_state(&config.rpc_client, &stake_address)?; + + let vote_account = match stake_state { + stake::state::StakeStateV2::Stake(_, stake, _) => Ok(stake.delegation.voter_pubkey), + _ => Err("Wrong stake account state, must be delegated to validator"), + }?; + + let validator_stake_info = validator_list + .find(&vote_account) + .ok_or("Vote account not found in the stake pool")?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + + // Calculate validator stake account address linked to the pool + let (validator_stake_account, _) = find_stake_program_address( + &spl_stake_pool::id(), + &vote_account, + stake_pool_address, + validator_seed, + ); + + let validator_stake_state = get_stake_state(&config.rpc_client, &validator_stake_account)?; + println!("Depositing user stake {}: {:?}", stake_address, stake_state); + println!( + "..into pool stake {}: {:?}", + validator_stake_account, validator_stake_state + ); + + let instructions = if let Some(stake_deposit_authority) = config.funding_authority.as_ref() + { + spl_stake_pool::instruction::deposit_stake_with_authority( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.validator_list, + &stake_deposit_authority.pubkey(), + &pool_withdraw_authority, + &stake_address, + &withdraw_authority.pubkey(), + &validator_stake_account, + &stake_pool.reserve_stake, + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + ) + } else { + spl_stake_pool::instruction::deposit_stake( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.validator_list, + &pool_withdraw_authority, + &stake_address, + &withdraw_authority.pubkey(), + &validator_stake_account, + &stake_pool.reserve_stake, + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + ) + }; + + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + send_transaction(config, transaction)?; + } + Ok(()) +} + +fn command_deposit_sol( + config: &Config, + stake_pool_address: &Pubkey, + from: &Option, + pool_token_receiver_account: &Option, + referrer_token_account: &Option, + amount: f64, +) -> CommandResult { + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + + let amount = native_token::sol_to_lamports(amount); + + // Check withdraw_from balance + let from_pubkey = from + .as_ref() + .map_or_else(|| config.fee_payer.pubkey(), |keypair| keypair.pubkey()); + let from_balance = config.rpc_client.get_balance(&from_pubkey)?; + if from_balance < amount { + return Err(format!( + "Not enough SOL to deposit into pool: {}.\nMaximum deposit amount is {} SOL.", + Sol(amount), + Sol(from_balance) + ) + .into()); + } + + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + + let mut instructions: Vec = vec![]; + + // ephemeral SOL account just to do the transfer + let user_sol_transfer = Keypair::new(); + let mut signers = vec![config.fee_payer.as_ref(), &user_sol_transfer]; + if let Some(keypair) = from.as_ref() { + signers.push(keypair) + } + + let mut total_rent_free_balances: u64 = 0; + + // Create the ephemeral SOL account + instructions.push(system_instruction::transfer( + &from_pubkey, + &user_sol_transfer.pubkey(), + amount, + )); + + // Create token account if not specified + let pool_token_receiver_account = + pool_token_receiver_account.unwrap_or(add_associated_token_account( + config, + &stake_pool.pool_mint, + &config.token_owner.pubkey(), + &mut instructions, + &mut total_rent_free_balances, + )); + + let referrer_token_account = referrer_token_account.unwrap_or(pool_token_receiver_account); + + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + + let deposit_instruction = if let Some(deposit_authority) = config.funding_authority.as_ref() { + let expected_sol_deposit_authority = stake_pool.sol_deposit_authority.ok_or_else(|| { + "SOL deposit authority specified in arguments but stake pool has none".to_string() + })?; + signers.push(deposit_authority.as_ref()); + if deposit_authority.pubkey() != expected_sol_deposit_authority { + let error = format!( + "Invalid deposit authority specified, expected {}, received {}", + expected_sol_deposit_authority, + deposit_authority.pubkey() + ); + return Err(error.into()); + } + + spl_stake_pool::instruction::deposit_sol_with_authority( + &spl_stake_pool::id(), + stake_pool_address, + &deposit_authority.pubkey(), + &pool_withdraw_authority, + &stake_pool.reserve_stake, + &user_sol_transfer.pubkey(), + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + amount, + ) + } else { + spl_stake_pool::instruction::deposit_sol( + &spl_stake_pool::id(), + stake_pool_address, + &pool_withdraw_authority, + &stake_pool.reserve_stake, + &user_sol_transfer.pubkey(), + &pool_token_receiver_account, + &stake_pool.manager_fee_account, + &referrer_token_account, + &stake_pool.pool_mint, + &spl_token::id(), + amount, + ) + }; + + instructions.push(deposit_instruction); + + unique_signers!(signers); + let transaction = checked_transaction_with_signers_and_additional_fee( + config, + &instructions, + &signers, + total_rent_free_balances, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_list(config: &Config, stake_pool_address: &Pubkey) -> CommandResult { + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + let reserve_stake_account_address = stake_pool.reserve_stake.to_string(); + let total_lamports = stake_pool.total_lamports; + let last_update_epoch = stake_pool.last_update_epoch; + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + let max_number_of_validators = validator_list.header.max_validators; + let current_number_of_validators = validator_list.validators.len(); + let pool_mint = get_token_mint(&config.rpc_client, &stake_pool.pool_mint)?; + let epoch_info = config.rpc_client.get_epoch_info()?; + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + let reserve_stake = config.rpc_client.get_account(&stake_pool.reserve_stake)?; + let minimum_reserve_stake_balance = config + .rpc_client + .get_minimum_balance_for_rent_exemption(STAKE_STATE_LEN)? + + MINIMUM_RESERVE_LAMPORTS; + let cli_stake_pool_stake_account_infos = validator_list + .validators + .iter() + .map(|validator| { + let validator_seed = NonZeroU32::new(validator.validator_seed_suffix.into()); + let (stake_account_address, _) = find_stake_program_address( + &spl_stake_pool::id(), + &validator.vote_account_address, + stake_pool_address, + validator_seed, + ); + let (transient_stake_account_address, _) = find_transient_stake_program_address( + &spl_stake_pool::id(), + &validator.vote_account_address, + stake_pool_address, + validator.transient_seed_suffix.into(), + ); + let update_required = u64::from(validator.last_update_epoch) != epoch_info.epoch; + CliStakePoolStakeAccountInfo { + vote_account_address: validator.vote_account_address.to_string(), + stake_account_address: stake_account_address.to_string(), + validator_active_stake_lamports: validator.active_stake_lamports.into(), + validator_last_update_epoch: validator.last_update_epoch.into(), + validator_lamports: validator.stake_lamports().unwrap(), + validator_transient_stake_account_address: transient_stake_account_address + .to_string(), + validator_transient_stake_lamports: validator.transient_stake_lamports.into(), + update_required, + } + }) + .collect(); + let total_pool_tokens = + spl_token::amount_to_ui_amount(stake_pool.pool_token_supply, pool_mint.decimals); + let mut cli_stake_pool = CliStakePool::from(( + *stake_pool_address, + stake_pool, + validator_list, + pool_withdraw_authority, + )); + let update_required = last_update_epoch != epoch_info.epoch; + let cli_stake_pool_details = CliStakePoolDetails { + reserve_stake_account_address, + reserve_stake_lamports: reserve_stake.lamports, + minimum_reserve_stake_balance, + stake_accounts: cli_stake_pool_stake_account_infos, + total_lamports, + total_pool_tokens, + current_number_of_validators: current_number_of_validators as u32, + max_number_of_validators, + update_required, + }; + cli_stake_pool.details = Some(cli_stake_pool_details); + println!("{}", config.output_format.formatted_string(&cli_stake_pool)); + Ok(()) +} + +fn command_update( + config: &Config, + stake_pool_address: &Pubkey, + force: bool, + no_merge: bool, + stale_only: bool, +) -> CommandResult { + if config.no_update { + println!("Update requested, but --no-update flag specified, so doing nothing"); + return Ok(()); + } + let stake_pool = get_stake_pool(&config.rpc_client, stake_pool_address)?; + let epoch_info = config.rpc_client.get_epoch_info()?; + + if stake_pool.last_update_epoch == epoch_info.epoch { + if force { + println!("Update not required, but --force flag specified, so doing it anyway"); + } else { + println!("Update not required"); + return Ok(()); + } + } + + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + + let (mut update_list_instructions, final_instructions) = if stale_only { + spl_stake_pool::instruction::update_stale_stake_pool( + &spl_stake_pool::id(), + &stake_pool, + &validator_list, + stake_pool_address, + no_merge, + epoch_info.epoch, + ) + } else { + spl_stake_pool::instruction::update_stake_pool( + &spl_stake_pool::id(), + &stake_pool, + &validator_list, + stake_pool_address, + no_merge, + ) + }; + + let update_list_instructions_len = update_list_instructions.len(); + if update_list_instructions_len > 0 { + let last_instruction = update_list_instructions.split_off(update_list_instructions_len - 1); + // send the first ones without waiting + for instruction in update_list_instructions { + let transaction = checked_transaction_with_signers( + config, + &[instruction], + &[config.fee_payer.as_ref()], + )?; + send_transaction_no_wait(config, transaction)?; + } + + // wait on the last one + let transaction = checked_transaction_with_signers( + config, + &last_instruction, + &[config.fee_payer.as_ref()], + )?; + send_transaction(config, transaction)?; + } + let transaction = checked_transaction_with_signers( + config, + &final_instructions, + &[config.fee_payer.as_ref()], + )?; + send_transaction(config, transaction)?; + + Ok(()) +} + +#[derive(PartialEq, Debug)] +struct WithdrawAccount { + stake_address: Pubkey, + vote_address: Option, + pool_amount: u64, +} + +fn sorted_accounts( + validator_list: &ValidatorList, + stake_pool: &StakePool, + get_info: F, +) -> Vec<(Pubkey, u64, Option)> +where + F: Fn(&ValidatorStakeInfo) -> (Pubkey, u64, Option), +{ + let mut result: Vec<(Pubkey, u64, Option)> = validator_list + .validators + .iter() + .map(get_info) + .collect::>(); + + result.sort_by(|left, right| { + if left.2 == stake_pool.preferred_withdraw_validator_vote_address { + Ordering::Less + } else if right.2 == stake_pool.preferred_withdraw_validator_vote_address { + Ordering::Greater + } else { + right.1.cmp(&left.1) + } + }); + + result +} + +fn prepare_withdraw_accounts( + rpc_client: &RpcClient, + stake_pool: &StakePool, + pool_amount: u64, + stake_pool_address: &Pubkey, + skip_fee: bool, +) -> Result, Error> { + let stake_minimum_delegation = rpc_client.get_stake_minimum_delegation()?; + let stake_pool_minimum_delegation = minimum_delegation(stake_minimum_delegation); + let min_balance = rpc_client + .get_minimum_balance_for_rent_exemption(STAKE_STATE_LEN)? + .saturating_add(stake_pool_minimum_delegation); + let pool_mint = get_token_mint(rpc_client, &stake_pool.pool_mint)?; + let validator_list: ValidatorList = get_validator_list(rpc_client, &stake_pool.validator_list)?; + + let mut accounts: Vec<(Pubkey, u64, Option)> = Vec::new(); + + accounts.append(&mut sorted_accounts( + &validator_list, + stake_pool, + |validator| { + let validator_seed = NonZeroU32::new(validator.validator_seed_suffix.into()); + let (stake_account_address, _) = find_stake_program_address( + &spl_stake_pool::id(), + &validator.vote_account_address, + stake_pool_address, + validator_seed, + ); + + ( + stake_account_address, + validator.active_stake_lamports.into(), + Some(validator.vote_account_address), + ) + }, + )); + + accounts.append(&mut sorted_accounts( + &validator_list, + stake_pool, + |validator| { + let (transient_stake_account_address, _) = find_transient_stake_program_address( + &spl_stake_pool::id(), + &validator.vote_account_address, + stake_pool_address, + validator.transient_seed_suffix.into(), + ); + + ( + transient_stake_account_address, + u64::from(validator.transient_stake_lamports).saturating_sub(min_balance), + Some(validator.vote_account_address), + ) + }, + )); + + let reserve_stake = rpc_client.get_account(&stake_pool.reserve_stake)?; + + accounts.push(( + stake_pool.reserve_stake, + reserve_stake.lamports + - rpc_client.get_minimum_balance_for_rent_exemption(STAKE_STATE_LEN)? + - MINIMUM_RESERVE_LAMPORTS, + None, + )); + + // Prepare the list of accounts to withdraw from + let mut withdraw_from: Vec = vec![]; + let mut remaining_amount = pool_amount; + + let fee = stake_pool.stake_withdrawal_fee; + let inverse_fee = Fee { + numerator: fee.denominator - fee.numerator, + denominator: fee.denominator, + }; + + // Go through available accounts and withdraw from largest to smallest + for (stake_address, lamports, vote_address_opt) in accounts { + if lamports <= min_balance { + continue; + } + + let available_for_withdrawal_wo_fee = + stake_pool.calc_pool_tokens_for_deposit(lamports).unwrap(); + + let available_for_withdrawal = if skip_fee { + available_for_withdrawal_wo_fee + } else { + available_for_withdrawal_wo_fee * inverse_fee.denominator / inverse_fee.numerator + }; + + let pool_amount = u64::min(available_for_withdrawal, remaining_amount); + + // Those accounts will be withdrawn completely with `claim` instruction + withdraw_from.push(WithdrawAccount { + stake_address, + vote_address: vote_address_opt, + pool_amount, + }); + remaining_amount -= pool_amount; + + if remaining_amount == 0 { + break; + } + } + + // Not enough stake to withdraw the specified amount + if remaining_amount > 0 { + return Err(format!( + "No stake accounts found in this pool with enough balance to withdraw {} pool tokens.", + spl_token::amount_to_ui_amount(pool_amount, pool_mint.decimals) + ) + .into()); + } + + Ok(withdraw_from) +} + +fn command_withdraw_stake( + config: &Config, + stake_pool_address: &Pubkey, + use_reserve: bool, + vote_account_address: &Option, + stake_receiver_param: &Option, + pool_token_account: &Option, + pool_amount: f64, +) -> CommandResult { + 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 pool_mint = get_token_mint(&config.rpc_client, &stake_pool.pool_mint)?; + let pool_amount = spl_token::ui_amount_to_amount(pool_amount, pool_mint.decimals); + + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + + let pool_token_account = pool_token_account.unwrap_or(get_associated_token_address( + &config.token_owner.pubkey(), + &stake_pool.pool_mint, + )); + let token_account = get_token_account( + &config.rpc_client, + &pool_token_account, + &stake_pool.pool_mint, + )?; + let stake_account_rent_exemption = config + .rpc_client + .get_minimum_balance_for_rent_exemption(STAKE_STATE_LEN)?; + + // Check withdraw_from balance + if token_account.amount < pool_amount { + return Err(format!( + "Not enough token balance to withdraw {} pool tokens.\nMaximum withdraw amount is {} pool tokens.", + spl_token::amount_to_ui_amount(pool_amount, pool_mint.decimals), + spl_token::amount_to_ui_amount(token_account.amount, pool_mint.decimals) + ) + .into()); + } + + // Check for the delegated stake receiver + let maybe_stake_receiver_state = stake_receiver_param + .map(|stake_receiver_pubkey| { + let stake_account = config.rpc_client.get_account(&stake_receiver_pubkey).ok()?; + let stake_state: stake::state::StakeStateV2 = + deserialize(stake_account.data.as_slice()) + .map_err(|err| { + format!("Invalid stake account {}: {}", stake_receiver_pubkey, err) + }) + .ok()?; + if stake_state.delegation().is_some() && stake_account.owner == stake::program::id() { + Some(stake_state) + } else { + None + } + }) + .flatten(); + + let stake_minimum_delegation = config.rpc_client.get_stake_minimum_delegation()?; + let stake_pool_minimum_delegation = minimum_delegation(stake_minimum_delegation); + + let withdraw_accounts = if use_reserve { + vec![WithdrawAccount { + stake_address: stake_pool.reserve_stake, + vote_address: None, + pool_amount, + }] + } else if maybe_stake_receiver_state.is_some() { + let vote_account = maybe_stake_receiver_state + .unwrap() + .delegation() + .unwrap() + .voter_pubkey; + if let Some(vote_account_address) = vote_account_address { + if *vote_account_address != vote_account { + return Err(format!("Provided withdrawal vote account {} does not match delegation on stake receiver account {}, + remove this flag or provide a different stake account delegated to {}", vote_account_address, vote_account, vote_account_address).into()); + } + } + // Check if the vote account exists in the stake pool + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + let validator_stake_info = validator_list + .find(&vote_account) + .ok_or(format!("Provided stake account is delegated to a vote account {} which does not exist in the stake pool", vote_account))?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + let (stake_account_address, _) = find_stake_program_address( + &spl_stake_pool::id(), + &vote_account, + stake_pool_address, + validator_seed, + ); + let stake_account = config.rpc_client.get_account(&stake_account_address)?; + + let available_for_withdrawal = stake_pool + .calc_lamports_withdraw_amount( + stake_account + .lamports + .saturating_sub(stake_pool_minimum_delegation) + .saturating_sub(stake_account_rent_exemption), + ) + .unwrap(); + + if available_for_withdrawal < pool_amount { + return Err(format!( + "Not enough lamports available for withdrawal from {}, {} asked, {} available", + stake_account_address, pool_amount, available_for_withdrawal + ) + .into()); + } + vec![WithdrawAccount { + stake_address: stake_account_address, + vote_address: Some(vote_account), + pool_amount, + }] + } else if let Some(vote_account_address) = vote_account_address { + let validator_list = get_validator_list(&config.rpc_client, &stake_pool.validator_list)?; + let validator_stake_info = validator_list.find(vote_account_address).ok_or(format!( + "Provided vote account address {} does not exist in the stake pool", + vote_account_address + ))?; + let validator_seed = NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()); + let (stake_account_address, _) = find_stake_program_address( + &spl_stake_pool::id(), + vote_account_address, + stake_pool_address, + validator_seed, + ); + let stake_account = config.rpc_client.get_account(&stake_account_address)?; + + let available_for_withdrawal = stake_pool + .calc_lamports_withdraw_amount( + stake_account + .lamports + .saturating_sub(stake_pool_minimum_delegation) + .saturating_sub(stake_account_rent_exemption), + ) + .unwrap(); + + if available_for_withdrawal < pool_amount { + return Err(format!( + "Not enough lamports available for withdrawal from {}, {} asked, {} available", + stake_account_address, pool_amount, available_for_withdrawal + ) + .into()); + } + vec![WithdrawAccount { + stake_address: stake_account_address, + vote_address: Some(*vote_account_address), + pool_amount, + }] + } else { + // Get the list of accounts to withdraw from + prepare_withdraw_accounts( + &config.rpc_client, + &stake_pool, + pool_amount, + stake_pool_address, + stake_pool.manager_fee_account == pool_token_account, + )? + }; + + // Construct transaction to withdraw from withdraw_accounts account list + let mut instructions: Vec = vec![]; + let user_transfer_authority = Keypair::new(); // ephemeral keypair just to do the transfer + let mut signers = vec![ + config.fee_payer.as_ref(), + config.token_owner.as_ref(), + &user_transfer_authority, + ]; + let mut new_stake_keypairs = vec![]; + + instructions.push( + // Approve spending token + spl_token::instruction::approve( + &spl_token::id(), + &pool_token_account, + &user_transfer_authority.pubkey(), + &config.token_owner.pubkey(), + &[], + pool_amount, + )?, + ); + + let mut total_rent_free_balances = 0; + // Go through prepared accounts and withdraw/claim them + for withdraw_account in withdraw_accounts { + // Convert pool tokens amount to lamports + let sol_withdraw_amount = stake_pool + .calc_lamports_withdraw_amount(withdraw_account.pool_amount) + .unwrap(); + + if let Some(vote_address) = withdraw_account.vote_address { + println!( + "Withdrawing {}, or {} pool tokens, from stake account {}, delegated to {}", + Sol(sol_withdraw_amount), + spl_token::amount_to_ui_amount(withdraw_account.pool_amount, pool_mint.decimals), + withdraw_account.stake_address, + vote_address, + ); + } else { + println!( + "Withdrawing {}, or {} pool tokens, from stake account {}", + Sol(sol_withdraw_amount), + spl_token::amount_to_ui_amount(withdraw_account.pool_amount, pool_mint.decimals), + withdraw_account.stake_address, + ); + } + let stake_receiver = + if (stake_receiver_param.is_none()) || (maybe_stake_receiver_state.is_some()) { + // Creating new account to split the stake into new account + let stake_keypair = new_stake_account( + &config.fee_payer.pubkey(), + &mut instructions, + stake_account_rent_exemption, + ); + let stake_pubkey = stake_keypair.pubkey(); + total_rent_free_balances += stake_account_rent_exemption; + new_stake_keypairs.push(stake_keypair); + stake_pubkey + } else { + stake_receiver_param.unwrap() + }; + + instructions.push(spl_stake_pool::instruction::withdraw_stake( + &spl_stake_pool::id(), + stake_pool_address, + &stake_pool.validator_list, + &pool_withdraw_authority, + &withdraw_account.stake_address, + &stake_receiver, + &config.staker.pubkey(), + &user_transfer_authority.pubkey(), + &pool_token_account, + &stake_pool.manager_fee_account, + &stake_pool.pool_mint, + &spl_token::id(), + withdraw_account.pool_amount, + )); + } + + // Merging the stake with account provided by user + if maybe_stake_receiver_state.is_some() { + for new_stake_keypair in &new_stake_keypairs { + instructions.extend(stake::instruction::merge( + &stake_receiver_param.unwrap(), + &new_stake_keypair.pubkey(), + &config.fee_payer.pubkey(), + )); + } + } + + for new_stake_keypair in &new_stake_keypairs { + signers.push(new_stake_keypair); + } + unique_signers!(signers); + let transaction = checked_transaction_with_signers_and_additional_fee( + config, + &instructions, + &signers, + total_rent_free_balances, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_withdraw_sol( + config: &Config, + stake_pool_address: &Pubkey, + pool_token_account: &Option, + sol_receiver: &Pubkey, + pool_amount: f64, +) -> CommandResult { + 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 pool_mint = get_token_mint(&config.rpc_client, &stake_pool.pool_mint)?; + let pool_amount = spl_token::ui_amount_to_amount(pool_amount, pool_mint.decimals); + + let pool_token_account = pool_token_account.unwrap_or(get_associated_token_address( + &config.token_owner.pubkey(), + &stake_pool.pool_mint, + )); + let token_account = get_token_account( + &config.rpc_client, + &pool_token_account, + &stake_pool.pool_mint, + )?; + + // Check withdraw_from balance + if token_account.amount < pool_amount { + return Err(format!( + "Not enough token balance to withdraw {} pool tokens.\nMaximum withdraw amount is {} pool tokens.", + spl_token::amount_to_ui_amount(pool_amount, pool_mint.decimals), + spl_token::amount_to_ui_amount(token_account.amount, pool_mint.decimals) + ) + .into()); + } + + // Construct transaction to withdraw from withdraw_accounts account list + let user_transfer_authority = Keypair::new(); // ephemeral keypair just to do the transfer + let mut signers = vec![ + config.fee_payer.as_ref(), + config.token_owner.as_ref(), + &user_transfer_authority, + ]; + + let mut instructions = vec![ + // Approve spending token + spl_token::instruction::approve( + &spl_token::id(), + &pool_token_account, + &user_transfer_authority.pubkey(), + &config.token_owner.pubkey(), + &[], + pool_amount, + )?, + ]; + + let pool_withdraw_authority = + find_withdraw_authority_program_address(&spl_stake_pool::id(), stake_pool_address).0; + + let withdraw_instruction = if let Some(withdraw_authority) = config.funding_authority.as_ref() { + let expected_sol_withdraw_authority = + stake_pool.sol_withdraw_authority.ok_or_else(|| { + "SOL withdraw authority specified in arguments but stake pool has none".to_string() + })?; + signers.push(withdraw_authority.as_ref()); + if withdraw_authority.pubkey() != expected_sol_withdraw_authority { + let error = format!( + "Invalid deposit withdraw specified, expected {}, received {}", + expected_sol_withdraw_authority, + withdraw_authority.pubkey() + ); + return Err(error.into()); + } + + spl_stake_pool::instruction::withdraw_sol_with_authority( + &spl_stake_pool::id(), + stake_pool_address, + &withdraw_authority.pubkey(), + &pool_withdraw_authority, + &user_transfer_authority.pubkey(), + &pool_token_account, + &stake_pool.reserve_stake, + sol_receiver, + &stake_pool.manager_fee_account, + &stake_pool.pool_mint, + &spl_token::id(), + pool_amount, + ) + } else { + spl_stake_pool::instruction::withdraw_sol( + &spl_stake_pool::id(), + stake_pool_address, + &pool_withdraw_authority, + &user_transfer_authority.pubkey(), + &pool_token_account, + &stake_pool.reserve_stake, + sol_receiver, + &stake_pool.manager_fee_account, + &stake_pool.pool_mint, + &spl_token::id(), + pool_amount, + ) + }; + + instructions.push(withdraw_instruction); + + unique_signers!(signers); + let transaction = checked_transaction_with_signers(config, &instructions, &signers)?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_set_manager( + config: &Config, + stake_pool_address: &Pubkey, + new_manager: &Option>, + new_fee_receiver: &Option, +) -> CommandResult { + 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)?; + + // If new accounts are missing in the arguments use the old ones + let (new_manager_pubkey, mut signers): (Pubkey, Vec<&dyn Signer>) = match new_manager { + None => (stake_pool.manager, vec![]), + Some(value) => (value.pubkey(), vec![value.as_ref()]), + }; + + let new_fee_receiver = match new_fee_receiver { + None => stake_pool.manager_fee_account, + Some(value) => { + // Check for fee receiver being a valid token account and have to same mint as + // the stake pool + let token_account = + get_token_account(&config.rpc_client, value, &stake_pool.pool_mint)?; + if token_account.mint != stake_pool.pool_mint { + return Err("Fee receiver account belongs to a different mint" + .to_string() + .into()); + } + *value + } + }; + + signers.append(&mut vec![ + config.fee_payer.as_ref(), + config.manager.as_ref(), + ]); + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[spl_stake_pool::instruction::set_manager( + &spl_stake_pool::id(), + stake_pool_address, + &config.manager.pubkey(), + &new_manager_pubkey, + &new_fee_receiver, + )], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_set_staker( + config: &Config, + stake_pool_address: &Pubkey, + new_staker: &Pubkey, +) -> CommandResult { + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[spl_stake_pool::instruction::set_staker( + &spl_stake_pool::id(), + stake_pool_address, + &config.manager.pubkey(), + new_staker, + )], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_set_funding_authority( + config: &Config, + stake_pool_address: &Pubkey, + new_authority: Option, + funding_type: FundingType, +) -> CommandResult { + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[spl_stake_pool::instruction::set_funding_authority( + &spl_stake_pool::id(), + stake_pool_address, + &config.manager.pubkey(), + new_authority.as_ref(), + funding_type, + )], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_set_fee( + config: &Config, + stake_pool_address: &Pubkey, + new_fee: FeeType, +) -> CommandResult { + if !config.no_update { + command_update(config, stake_pool_address, false, false, false)?; + } + let mut signers = vec![config.fee_payer.as_ref(), config.manager.as_ref()]; + unique_signers!(signers); + let transaction = checked_transaction_with_signers( + config, + &[spl_stake_pool::instruction::set_fee( + &spl_stake_pool::id(), + stake_pool_address, + &config.manager.pubkey(), + new_fee, + )], + &signers, + )?; + send_transaction(config, transaction)?; + Ok(()) +} + +fn command_list_all_pools(config: &Config) -> CommandResult { + let all_pools = get_stake_pools(&config.rpc_client)?; + let cli_stake_pool_vec: Vec = + all_pools.into_iter().map(CliStakePool::from).collect(); + let cli_stake_pools = CliStakePools { + pools: cli_stake_pool_vec, + }; + println!( + "{}", + config.output_format.formatted_string(&cli_stake_pools) + ); + Ok(()) +} + +fn main() { + solana_logger::setup_with_default("solana=info"); + + let matches = App::new(crate_name!()) + .about(crate_description!()) + .version(crate_version!()) + .setting(AppSettings::SubcommandRequiredElseHelp) + .arg({ + let arg = Arg::with_name("config_file") + .short("C") + .long("config") + .value_name("PATH") + .takes_value(true) + .global(true) + .help("Configuration file to use"); + if let Some(ref config_file) = *solana_cli_config::CONFIG_FILE { + arg.default_value(config_file) + } else { + arg + } + }) + .arg( + Arg::with_name("verbose") + .long("verbose") + .short("v") + .takes_value(false) + .global(true) + .help("Show additional information"), + ) + .arg( + Arg::with_name("output_format") + .long("output") + .value_name("FORMAT") + .global(true) + .takes_value(true) + .possible_values(&["json", "json-compact"]) + .help("Return information in specified output format"), + ) + .arg( + Arg::with_name("dry_run") + .long("dry-run") + .takes_value(false) + .global(true) + .help("Simulate transaction instead of executing"), + ) + .arg( + Arg::with_name("no_update") + .long("no-update") + .takes_value(false) + .global(true) + .help("Do not automatically update the stake pool if needed"), + ) + .arg( + Arg::with_name("json_rpc_url") + .long("url") + .value_name("URL") + .takes_value(true) + .validator(is_url) + .global(true) + .help("JSON RPC URL for the cluster. Default from the configuration file."), + ) + .arg( + Arg::with_name("staker") + .long("staker") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Stake pool staker. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("manager") + .long("manager") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Stake pool manager. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("funding_authority") + .long("funding-authority") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Stake pool funding authority for deposits or withdrawals. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("token_owner") + .long("token-owner") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Owner of pool token account [default: cli config keypair]"), + ) + .arg( + Arg::with_name("fee_payer") + .long("fee-payer") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Transaction fee payer account [default: cli config keypair]"), + ) + .arg(compute_unit_price_arg().validator(is_parsable::).global(true)) + .arg( + Arg::with_name(COMPUTE_UNIT_LIMIT_ARG.name) + .long(COMPUTE_UNIT_LIMIT_ARG.long) + .takes_value(true) + .value_name("COMPUTE-UNIT-LIMIT") + .help(COMPUTE_UNIT_LIMIT_ARG.help) + .validator(is_compute_unit_limit_or_simulated) + .global(true) + ) + .subcommand(SubCommand::with_name("create-pool") + .about("Create a new stake pool") + .arg( + Arg::with_name("epoch_fee_numerator") + .long("epoch-fee-numerator") + .short("n") + .validator(is_parsable::) + .value_name("NUMERATOR") + .takes_value(true) + .required(true) + .help("Epoch fee numerator, fee amount is numerator divided by denominator."), + ) + .arg( + Arg::with_name("epoch_fee_denominator") + .long("epoch-fee-denominator") + .short("d") + .validator(is_parsable::) + .value_name("DENOMINATOR") + .takes_value(true) + .required(true) + .help("Epoch fee denominator, fee amount is numerator divided by denominator."), + ) + .arg( + Arg::with_name("withdrawal_fee_numerator") + .long("withdrawal-fee-numerator") + .validator(is_parsable::) + .value_name("NUMERATOR") + .takes_value(true) + .requires("withdrawal_fee_denominator") + .help("Withdrawal fee numerator, fee amount is numerator divided by denominator [default: 0]"), + ).arg( + Arg::with_name("withdrawal_fee_denominator") + .long("withdrawal-fee-denominator") + .validator(is_parsable::) + .value_name("DENOMINATOR") + .takes_value(true) + .requires("withdrawal_fee_numerator") + .help("Withdrawal fee denominator, fee amount is numerator divided by denominator [default: 0]"), + ) + .arg( + Arg::with_name("deposit_fee_numerator") + .long("deposit-fee-numerator") + .validator(is_parsable::) + .value_name("NUMERATOR") + .takes_value(true) + .requires("deposit_fee_denominator") + .help("Deposit fee numerator, fee amount is numerator divided by denominator [default: 0]"), + ).arg( + Arg::with_name("deposit_fee_denominator") + .long("deposit-fee-denominator") + .validator(is_parsable::) + .value_name("DENOMINATOR") + .takes_value(true) + .requires("deposit_fee_numerator") + .help("Deposit fee denominator, fee amount is numerator divided by denominator [default: 0]"), + ) + .arg( + Arg::with_name("referral_fee") + .long("referral-fee") + .validator(is_valid_percentage) + .value_name("FEE_PERCENTAGE") + .takes_value(true) + .help("Referral fee percentage, maximum 100"), + ) + .arg( + Arg::with_name("max_validators") + .long("max-validators") + .short("m") + .validator(is_parsable::) + .value_name("NUMBER") + .takes_value(true) + .required(true) + .help("Max number of validators included in the stake pool"), + ) + .arg( + Arg::with_name("deposit_authority") + .long("deposit-authority") + .short("a") + .validator(is_valid_signer) + .value_name("DEPOSIT_AUTHORITY_KEYPAIR") + .takes_value(true) + .help("Deposit authority required to sign all deposits into the stake pool"), + ) + .arg( + Arg::with_name("pool_keypair") + .long("pool-keypair") + .short("p") + .validator(is_keypair_or_ask_keyword) + .value_name("PATH") + .takes_value(true) + .help("Stake pool keypair [default: new keypair]"), + ) + .arg( + Arg::with_name("validator_list_keypair") + .long("validator-list-keypair") + .validator(is_keypair_or_ask_keyword) + .value_name("PATH") + .takes_value(true) + .help("Validator list keypair [default: new keypair]"), + ) + .arg( + Arg::with_name("mint_keypair") + .long("mint-keypair") + .validator(is_keypair_or_ask_keyword) + .value_name("PATH") + .takes_value(true) + .help("Stake pool mint keypair [default: new keypair]"), + ) + .arg( + Arg::with_name("reserve_keypair") + .long("reserve-keypair") + .validator(is_keypair_or_ask_keyword) + .value_name("PATH") + .takes_value(true) + .help("Stake pool reserve keypair [default: new keypair]"), + ) + .arg( + Arg::with_name("unsafe_fees") + .long("unsafe-fees") + .takes_value(false) + .help("Bypass fee checks, allowing pool to be created with unsafe fees"), + ) + ) + .subcommand(SubCommand::with_name("create-token-metadata") + .about("Creates stake pool token metadata") + .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("name") + .index(2) + .value_name("TOKEN_NAME") + .takes_value(true) + .required(true) + .help("Name of the token"), + ) + .arg( + Arg::with_name("symbol") + .index(3) + .value_name("TOKEN_SYMBOL") + .takes_value(true) + .required(true) + .help("Symbol of the token"), + ) + .arg( + Arg::with_name("uri") + .index(4) + .value_name("TOKEN_URI") + .takes_value(true) + .required(true) + .help("URI of the token metadata json"), + ) + ) + .subcommand(SubCommand::with_name("update-token-metadata") + .about("Updates stake pool token metadata") + .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("name") + .index(2) + .value_name("TOKEN_NAME") + .takes_value(true) + .required(true) + .help("Name of the token"), + ) + .arg( + Arg::with_name("symbol") + .index(3) + .value_name("TOKEN_SYMBOL") + .takes_value(true) + .required(true) + .help("Symbol of the token"), + ) + .arg( + Arg::with_name("uri") + .index(4) + .value_name("TOKEN_URI") + .takes_value(true) + .required(true) + .help("URI of the token metadata json"), + ) + ) + .subcommand(SubCommand::with_name("add-validator") + .about("Add validator account to the stake pool. 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("The validator vote account that the stake is delegated to"), + ) + ) + .subcommand(SubCommand::with_name("remove-validator") + .about("Remove validator account from the stake pool. 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 remove from the pool"), + ) + ) + .subcommand(SubCommand::with_name("increase-validator-stake") + .about("Increase stake to a validator, drawing from the stake pool reserve. 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. Must be at least the rent-exempt amount for a stake plus 1 SOL for merging."), + ) + ) + .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.") + .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. Must be at least the rent-exempt amount for a stake."), + ) + ) + .subcommand(SubCommand::with_name("set-preferred-validator") + .about("Set the preferred validator for deposits or withdrawals. 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("preferred_type") + .index(2) + .value_name("OPERATION") + .possible_values(&["deposit", "withdraw"]) // PreferredValidatorType enum + .takes_value(true) + .required(true) + .help("Operation for which to restrict the validator"), + ) + .arg( + Arg::with_name("vote_account") + .long("vote-account") + .validator(is_pubkey) + .value_name("VOTE_ACCOUNT_ADDRESS") + .takes_value(true) + .help("Vote account for the validator that users must deposit into."), + ) + .arg( + Arg::with_name("unset") + .long("unset") + .takes_value(false) + .help("Unset the preferred validator."), + ) + .group(ArgGroup::with_name("validator") + .arg("vote_account") + .arg("unset") + .required(true) + ) + ) + .subcommand(SubCommand::with_name("deposit-stake") + .about("Deposit active stake account into the stake pool in exchange for pool tokens") + .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("stake_account") + .index(2) + .validator(is_pubkey) + .value_name("STAKE_ACCOUNT_ADDRESS") + .takes_value(true) + .required(true) + .help("Stake address to join the pool"), + ) + .arg( + Arg::with_name("withdraw_authority") + .long("withdraw-authority") + .validator(is_valid_signer) + .value_name("KEYPAIR") + .takes_value(true) + .help("Withdraw authority for the stake account to be deposited. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("token_receiver") + .long("token-receiver") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Account to receive the minted pool tokens. \ + Defaults to the token-owner's associated pool token account. \ + Creates the account if it does not exist."), + ) + .arg( + Arg::with_name("referrer") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Pool token account to receive the referral fees for deposits. \ + Defaults to the token receiver."), + ) + ) + .subcommand(SubCommand::with_name("deposit-all-stake") + .about("Deposit all active stake accounts into the stake pool in exchange for pool tokens") + .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("stake_authority") + .index(2) + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .required(true) + .help("Stake authority address to search for stake accounts"), + ) + .arg( + Arg::with_name("withdraw_authority") + .long("withdraw-authority") + .validator(is_valid_signer) + .value_name("KEYPAIR") + .takes_value(true) + .help("Withdraw authority for the stake account to be deposited. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("token_receiver") + .long("token-receiver") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Account to receive the minted pool tokens. \ + Defaults to the token-owner's associated pool token account. \ + Creates the account if it does not exist."), + ) + .arg( + Arg::with_name("referrer") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Pool token account to receive the referral fees for deposits. \ + Defaults to the token receiver."), + ) + ) + .subcommand(SubCommand::with_name("deposit-sol") + .about("Deposit SOL into the stake pool in exchange for pool tokens") + .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("amount") + .index(2) + .validator(is_amount) + .value_name("AMOUNT") + .takes_value(true) + .help("Amount in SOL to deposit into the stake pool reserve account."), + ) + .arg( + Arg::with_name("from") + .long("from") + .validator(is_valid_signer) + .value_name("KEYPAIR") + .takes_value(true) + .help("Source account of funds. [default: cli config keypair]"), + ) + .arg( + Arg::with_name("token_receiver") + .long("token-receiver") + .validator(is_pubkey) + .value_name("POOL_TOKEN_RECEIVER_ADDRESS") + .takes_value(true) + .help("Account to receive the minted pool tokens. \ + Defaults to the token-owner's associated pool token account. \ + Creates the account if it does not exist."), + ) + .arg( + Arg::with_name("referrer") + .long("referrer") + .validator(is_pubkey) + .value_name("REFERRER_TOKEN_ADDRESS") + .takes_value(true) + .help("Account to receive the referral fees for deposits. \ + Defaults to the token receiver."), + ) + ) + .subcommand(SubCommand::with_name("list") + .about("List stake accounts managed by this pool") + .arg( + Arg::with_name("pool") + .index(1) + .validator(is_pubkey) + .value_name("POOL_ADDRESS") + .takes_value(true) + .required(true) + .help("Stake pool address."), + ) + ) + .subcommand(SubCommand::with_name("update") + .about("Updates all balances in the pool after validator stake accounts receive rewards.") + .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("force") + .long("force") + .takes_value(false) + .help("Update balances, even if it has already been performed this epoch."), + ) + .arg( + Arg::with_name("no_merge") + .long("no-merge") + .takes_value(false) + .help("Do not automatically merge transient stakes. Useful if the stake pool is in an expected state, but the balances still need to be updated."), + ) + .arg( + Arg::with_name("stale_only") + .long("stale-only") + .takes_value(false) + .help("If set, only updates validator list balances that have not been updated for this epoch. Otherwise, updates all validator balances on the validator list."), + ) + ) + .subcommand(SubCommand::with_name("withdraw-stake") + .about("Withdraw active stake from the stake pool in exchange for pool tokens") + .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("amount") + .index(2) + .validator(is_amount) + .value_name("AMOUNT") + .takes_value(true) + .required(true) + .help("Amount of pool tokens to withdraw for activated stake."), + ) + .arg( + Arg::with_name("pool_account") + .long("pool-account") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Pool token account to withdraw tokens from. Defaults to the token-owner's associated token account."), + ) + .arg( + Arg::with_name("stake_receiver") + .long("stake-receiver") + .validator(is_pubkey) + .value_name("STAKE_ACCOUNT_ADDRESS") + .takes_value(true) + .requires("withdraw_from") + .help("Stake account from which to receive a stake from the stake pool. Defaults to a new stake account."), + ) + .arg( + Arg::with_name("vote_account") + .long("vote-account") + .validator(is_pubkey) + .value_name("VOTE_ACCOUNT_ADDRESS") + .takes_value(true) + .help("Validator to withdraw from. Defaults to the largest validator stakes in the pool."), + ) + .arg( + Arg::with_name("use_reserve") + .long("use-reserve") + .takes_value(false) + .help("Withdraw from the stake pool's reserve. Only possible if all validator stakes are at the minimum possible amount."), + ) + .group(ArgGroup::with_name("withdraw_from") + .arg("use_reserve") + .arg("vote_account") + ) + ) + .subcommand(SubCommand::with_name("withdraw-sol") + .about("Withdraw SOL from the stake pool's reserve in exchange for pool tokens") + .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("sol_receiver") + .index(2) + .validator(is_valid_pubkey) + .value_name("SYSTEM_ACCOUNT_ADDRESS_OR_KEYPAIR") + .takes_value(true) + .required(true) + .help("System account to receive SOL from the stake pool. Defaults to the payer."), + ) + .arg( + Arg::with_name("amount") + .index(3) + .validator(is_amount) + .value_name("AMOUNT") + .takes_value(true) + .required(true) + .help("Amount of pool tokens to withdraw for SOL."), + ) + .arg( + Arg::with_name("pool_account") + .long("pool-account") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Pool token account to withdraw tokens from. Defaults to the token-owner's associated token account."), + ) + ) + .subcommand(SubCommand::with_name("set-manager") + .about("Change manager or fee receiver account for the stake pool. Must be signed by the current manager.") + .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("new_manager") + .long("new-manager") + .validator(is_valid_signer) + .value_name("KEYPAIR") + .takes_value(true) + .help("Keypair for the new stake pool manager."), + ) + .arg( + Arg::with_name("new_fee_receiver") + .long("new-fee-receiver") + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Public key for the new account to set as the stake pool fee receiver."), + ) + .group(ArgGroup::with_name("new_accounts") + .arg("new_manager") + .arg("new_fee_receiver") + .required(true) + .multiple(true) + ) + ) + .subcommand(SubCommand::with_name("set-staker") + .about("Change staker account for the stake pool. Must be signed by the manager or current 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("new_staker") + .index(2) + .validator(is_pubkey) + .value_name("ADDRESS") + .takes_value(true) + .help("Public key for the new stake pool staker."), + ) + ) + .subcommand(SubCommand::with_name("set-funding-authority") + .about("Change one of the funding authorities for the stake pool. Must be signed by the manager.") + .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("funding_type") + .index(2) + .value_name("FUNDING_TYPE") + .possible_values(&["stake-deposit", "sol-deposit", "sol-withdraw"]) // FundingType enum + .takes_value(true) + .required(true) + .help("Funding type to be updated."), + ) + .arg( + Arg::with_name("new_authority") + .index(3) + .validator(is_pubkey) + .value_name("AUTHORITY_ADDRESS") + .takes_value(true) + .help("Public key for the new stake pool funding authority."), + ) + .arg( + Arg::with_name("unset") + .long("unset") + .takes_value(false) + .help("Unset the stake deposit authority. The program will use a program derived address.") + ) + .group(ArgGroup::with_name("validator") + .arg("new_authority") + .arg("unset") + .required(true) + ) + ) + .subcommand(SubCommand::with_name("set-fee") + .about("Change the [epoch/withdraw/stake deposit/sol deposit] fee assessed by the stake pool. Must be signed by the manager.") + .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("fee_type") + .index(2) + .value_name("FEE_TYPE") + .possible_values(&["epoch", "stake-deposit", "sol-deposit", "stake-withdrawal", "sol-withdrawal"]) // FeeType enum + .takes_value(true) + .required(true) + .help("Fee type to be updated."), + ) + .arg( + Arg::with_name("fee_numerator") + .index(3) + .validator(is_parsable::) + .value_name("NUMERATOR") + .takes_value(true) + .required(true) + .help("Fee numerator, fee amount is numerator divided by denominator."), + ) + .arg( + Arg::with_name("fee_denominator") + .index(4) + .validator(is_parsable::) + .value_name("DENOMINATOR") + .takes_value(true) + .required(true) + .help("Fee denominator, fee amount is numerator divided by denominator."), + ) + ) + .subcommand(SubCommand::with_name("set-referral-fee") + .about("Change the referral fee assessed by the stake pool for stake deposits. Must be signed by the manager.") + .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("fee_type") + .index(2) + .value_name("FEE_TYPE") + .possible_values(&["stake", "sol"]) // FeeType enum, kind of + .takes_value(true) + .required(true) + .help("Fee type to be updated."), + ) + .arg( + Arg::with_name("fee") + .index(3) + .validator(is_valid_percentage) + .value_name("FEE_PERCENTAGE") + .takes_value(true) + .required(true) + .help("Fee percentage, maximum 100"), + ) + ) + .subcommand(SubCommand::with_name("list-all") + .about("List information about all stake pools") + ) + .get_matches(); + + let mut wallet_manager = None; + let cli_config = if let Some(config_file) = matches.value_of("config_file") { + solana_cli_config::Config::load(config_file).unwrap_or_default() + } else { + solana_cli_config::Config::default() + }; + let config = { + let json_rpc_url = value_t!(matches, "json_rpc_url", String) + .unwrap_or_else(|_| cli_config.json_rpc_url.clone()); + + let staker = get_signer( + &matches, + "staker", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + + let funding_authority = if matches.is_present("funding_authority") { + Some(get_signer( + &matches, + "funding_authority", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + )) + } else { + None + }; + let manager = get_signer( + &matches, + "manager", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + let token_owner = get_signer( + &matches, + "token_owner", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + let fee_payer = get_signer( + &matches, + "fee_payer", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + let verbose = matches.is_present("verbose"); + let output_format = matches + .value_of("output_format") + .map(|value| match value { + "json" => OutputFormat::Json, + "json-compact" => OutputFormat::JsonCompact, + _ => unreachable!(), + }) + .unwrap_or(if verbose { + OutputFormat::DisplayVerbose + } else { + OutputFormat::Display + }); + let dry_run = matches.is_present("dry_run"); + let no_update = matches.is_present("no_update"); + let compute_unit_price = value_t!(matches, COMPUTE_UNIT_PRICE_ARG.name, u64).ok(); + let compute_unit_limit = matches + .value_of(COMPUTE_UNIT_LIMIT_ARG.name) + .map(|x| parse_compute_unit_limit(x).unwrap()) + .unwrap_or_else(|| { + if compute_unit_price.is_some() { + ComputeUnitLimit::Simulated + } else { + ComputeUnitLimit::Default + } + }); + + Config { + rpc_client: RpcClient::new_with_commitment(json_rpc_url, CommitmentConfig::confirmed()), + verbose, + output_format, + manager, + staker, + funding_authority, + token_owner, + fee_payer, + dry_run, + no_update, + compute_unit_price, + compute_unit_limit, + } + }; + + let _ = match matches.subcommand() { + ("create-pool", Some(arg_matches)) => { + let deposit_authority = keypair_of(arg_matches, "deposit_authority"); + let e_numerator = value_t_or_exit!(arg_matches, "epoch_fee_numerator", u64); + let e_denominator = value_t_or_exit!(arg_matches, "epoch_fee_denominator", u64); + let w_numerator = value_t!(arg_matches, "withdrawal_fee_numerator", u64); + let w_denominator = value_t!(arg_matches, "withdrawal_fee_denominator", u64); + let d_numerator = value_t!(arg_matches, "deposit_fee_numerator", u64); + let d_denominator = value_t!(arg_matches, "deposit_fee_denominator", u64); + let referral_fee = value_t!(arg_matches, "referral_fee", u8); + let max_validators = value_t_or_exit!(arg_matches, "max_validators", u32); + let pool_keypair = keypair_of(arg_matches, "pool_keypair"); + let validator_list_keypair = keypair_of(arg_matches, "validator_list_keypair"); + let mint_keypair = keypair_of(arg_matches, "mint_keypair"); + let reserve_keypair = keypair_of(arg_matches, "reserve_keypair"); + let unsafe_fees = arg_matches.is_present("unsafe_fees"); + command_create_pool( + &config, + deposit_authority, + Fee { + numerator: e_numerator, + denominator: e_denominator, + }, + Fee { + numerator: w_numerator.unwrap_or(0), + denominator: w_denominator.unwrap_or(0), + }, + Fee { + numerator: d_numerator.unwrap_or(0), + denominator: d_denominator.unwrap_or(0), + }, + referral_fee.unwrap_or(0), + max_validators, + pool_keypair, + validator_list_keypair, + mint_keypair, + reserve_keypair, + unsafe_fees, + ) + } + ("create-token-metadata", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let name = value_t_or_exit!(arg_matches, "name", String); + let symbol = value_t_or_exit!(arg_matches, "symbol", String); + let uri = value_t_or_exit!(arg_matches, "uri", String); + create_token_metadata(&config, &stake_pool_address, name, symbol, uri) + } + ("update-token-metadata", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let name = value_t_or_exit!(arg_matches, "name", String); + let symbol = value_t_or_exit!(arg_matches, "symbol", String); + let uri = value_t_or_exit!(arg_matches, "uri", String); + update_token_metadata(&config, &stake_pool_address, name, symbol, uri) + } + ("add-validator", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let vote_account_address = pubkey_of(arg_matches, "vote_account").unwrap(); + command_vsa_add(&config, &stake_pool_address, &vote_account_address) + } + ("remove-validator", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let vote_account = pubkey_of(arg_matches, "vote_account").unwrap(); + command_vsa_remove(&config, &stake_pool_address, &vote_account) + } + ("increase-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 = value_t_or_exit!(arg_matches, "amount", f64); + command_increase_validator_stake(&config, &stake_pool_address, &vote_account, amount) + } + ("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 = value_t_or_exit!(arg_matches, "amount", f64); + command_decrease_validator_stake(&config, &stake_pool_address, &vote_account, amount) + } + ("set-preferred-validator", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let preferred_type = match arg_matches.value_of("preferred_type").unwrap() { + "deposit" => PreferredValidatorType::Deposit, + "withdraw" => PreferredValidatorType::Withdraw, + _ => unreachable!(), + }; + let vote_account = pubkey_of(arg_matches, "vote_account"); + let _unset = arg_matches.is_present("unset"); + // since unset and vote_account can't both be set, if unset is set + // then vote_account will be None, which is valid for the program + command_set_preferred_validator( + &config, + &stake_pool_address, + preferred_type, + vote_account, + ) + } + ("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(); + let token_receiver: Option = pubkey_of(arg_matches, "token_receiver"); + let referrer: Option = pubkey_of(arg_matches, "referrer"); + let withdraw_authority = get_signer( + arg_matches, + "withdraw_authority", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + command_deposit_stake( + &config, + &stake_pool_address, + &stake_account, + withdraw_authority, + &token_receiver, + &referrer, + ) + } + ("deposit-sol", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let token_receiver: Option = pubkey_of(arg_matches, "token_receiver"); + let referrer: Option = pubkey_of(arg_matches, "referrer"); + let from = keypair_of(arg_matches, "from"); + let amount = value_t_or_exit!(arg_matches, "amount", f64); + command_deposit_sol( + &config, + &stake_pool_address, + &from, + &token_receiver, + &referrer, + amount, + ) + } + ("list", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + command_list(&config, &stake_pool_address) + } + ("update", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let no_merge = arg_matches.is_present("no_merge"); + let force = arg_matches.is_present("force"); + let stale_only = arg_matches.is_present("stale_only"); + command_update(&config, &stake_pool_address, force, no_merge, stale_only) + } + ("withdraw-stake", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let vote_account = pubkey_of(arg_matches, "vote_account"); + let pool_account = pubkey_of(arg_matches, "pool_account"); + let pool_amount = value_t_or_exit!(arg_matches, "amount", f64); + let stake_receiver = pubkey_of(arg_matches, "stake_receiver"); + let use_reserve = arg_matches.is_present("use_reserve"); + command_withdraw_stake( + &config, + &stake_pool_address, + use_reserve, + &vote_account, + &stake_receiver, + &pool_account, + pool_amount, + ) + } + ("withdraw-sol", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let pool_account = pubkey_of(arg_matches, "pool_account"); + let pool_amount = value_t_or_exit!(arg_matches, "amount", f64); + let sol_receiver = get_signer( + arg_matches, + "sol_receiver", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: true, + }, + ) + .pubkey(); + command_withdraw_sol( + &config, + &stake_pool_address, + &pool_account, + &sol_receiver, + pool_amount, + ) + } + ("set-manager", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + + let new_manager = if arg_matches.value_of("new_manager").is_some() { + let signer = get_signer( + arg_matches, + "new-manager", + arg_matches + .value_of("new_manager") + .expect("new manager argument not found!"), + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: true, + }, + ); + Some(signer) + } else { + None + }; + + let new_fee_receiver: Option = pubkey_of(arg_matches, "new_fee_receiver"); + command_set_manager( + &config, + &stake_pool_address, + &new_manager, + &new_fee_receiver, + ) + } + ("set-staker", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let new_staker = pubkey_of(arg_matches, "new_staker").unwrap(); + command_set_staker(&config, &stake_pool_address, &new_staker) + } + ("set-funding-authority", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let new_authority = pubkey_of(arg_matches, "new_authority"); + let funding_type = match arg_matches.value_of("funding_type").unwrap() { + "sol-deposit" => FundingType::SolDeposit, + "stake-deposit" => FundingType::StakeDeposit, + "sol-withdraw" => FundingType::SolWithdraw, + _ => unreachable!(), + }; + let _unset = arg_matches.is_present("unset"); + command_set_funding_authority(&config, &stake_pool_address, new_authority, funding_type) + } + ("set-fee", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let numerator = value_t_or_exit!(arg_matches, "fee_numerator", u64); + let denominator = value_t_or_exit!(arg_matches, "fee_denominator", u64); + let new_fee = Fee { + denominator, + numerator, + }; + match arg_matches.value_of("fee_type").unwrap() { + "epoch" => command_set_fee(&config, &stake_pool_address, FeeType::Epoch(new_fee)), + "stake-deposit" => { + command_set_fee(&config, &stake_pool_address, FeeType::StakeDeposit(new_fee)) + } + "sol-deposit" => { + command_set_fee(&config, &stake_pool_address, FeeType::SolDeposit(new_fee)) + } + "stake-withdrawal" => command_set_fee( + &config, + &stake_pool_address, + FeeType::StakeWithdrawal(new_fee), + ), + "sol-withdrawal" => command_set_fee( + &config, + &stake_pool_address, + FeeType::SolWithdrawal(new_fee), + ), + _ => unreachable!(), + } + } + ("set-referral-fee", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let fee = value_t_or_exit!(arg_matches, "fee", u8); + assert!( + fee <= 100u8, + "Invalid fee {}%. Fee needs to be in range [0-100]", + fee + ); + let fee_type = match arg_matches.value_of("fee_type").unwrap() { + "sol" => FeeType::SolReferral(fee), + "stake" => FeeType::StakeReferral(fee), + _ => unreachable!(), + }; + command_set_fee(&config, &stake_pool_address, fee_type) + } + ("list-all", _) => command_list_all_pools(&config), + ("deposit-all-stake", Some(arg_matches)) => { + let stake_pool_address = pubkey_of(arg_matches, "pool").unwrap(); + let stake_authority = pubkey_of(arg_matches, "stake_authority").unwrap(); + let token_receiver: Option = pubkey_of(arg_matches, "token_receiver"); + let referrer: Option = pubkey_of(arg_matches, "referrer"); + let withdraw_authority = get_signer( + arg_matches, + "withdraw_authority", + &cli_config.keypair_path, + &mut wallet_manager, + SignerFromPathConfig { + allow_null_signer: false, + }, + ); + command_deposit_all_stake( + &config, + &stake_pool_address, + &stake_authority, + withdraw_authority, + &token_receiver, + &referrer, + ) + } + _ => unreachable!(), + } + .map_err(|err| { + eprintln!("{}", err); + exit(1); + }); +} diff --git a/clients/cli/src/output.rs b/clients/cli/src/output.rs new file mode 100644 index 00000000..39d85a0b --- /dev/null +++ b/clients/cli/src/output.rs @@ -0,0 +1,505 @@ +use { + serde::{Deserialize, Serialize}, + solana_cli_output::{QuietDisplay, VerboseDisplay}, + solana_sdk::{native_token::Sol, pubkey::Pubkey, stake::state::Lockup}, + spl_stake_pool::state::{ + Fee, PodStakeStatus, StakePool, StakeStatus, ValidatorList, ValidatorStakeInfo, + }, + std::fmt::{Display, Formatter, Result, Write}, +}; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePools { + pub pools: Vec, +} + +impl Display for CliStakePools { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + for pool in &self.pools { + writeln!( + f, + "Address: {}\tManager: {}\tLamports: {}\tPool tokens: {}\tValidators: {}", + pool.address, + pool.manager, + pool.total_lamports, + pool.pool_token_supply, + pool.validator_list.len() + )?; + } + writeln!(f, "Total number of pools: {}", &self.pools.len())?; + Ok(()) + } +} + +impl QuietDisplay for CliStakePools {} +impl VerboseDisplay for CliStakePools {} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePool { + pub address: String, + pub pool_withdraw_authority: String, + pub manager: String, + pub staker: String, + pub stake_deposit_authority: String, + pub stake_withdraw_bump_seed: u8, + pub max_validators: u32, + pub validator_list: Vec, + pub validator_list_storage_account: String, + pub reserve_stake: String, + pub pool_mint: String, + pub manager_fee_account: String, + pub token_program_id: String, + pub total_lamports: u64, + pub pool_token_supply: u64, + pub last_update_epoch: u64, + pub lockup: CliStakePoolLockup, + pub epoch_fee: CliStakePoolFee, + pub next_epoch_fee: Option, + pub preferred_deposit_validator_vote_address: Option, + pub preferred_withdraw_validator_vote_address: Option, + pub stake_deposit_fee: CliStakePoolFee, + pub stake_withdrawal_fee: CliStakePoolFee, + pub next_stake_withdrawal_fee: Option, + pub stake_referral_fee: u8, + pub sol_deposit_authority: Option, + pub sol_deposit_fee: CliStakePoolFee, + pub sol_referral_fee: u8, + pub sol_withdraw_authority: Option, + pub sol_withdrawal_fee: CliStakePoolFee, + pub next_sol_withdrawal_fee: Option, + pub last_epoch_pool_token_supply: u64, + pub last_epoch_total_lamports: u64, + pub details: Option, +} + +impl QuietDisplay for CliStakePool {} +impl VerboseDisplay for CliStakePool { + fn write_str(&self, w: &mut dyn Write) -> Result { + writeln!(w, "Stake Pool Info")?; + writeln!(w, "===============")?; + writeln!(w, "Stake Pool: {}", &self.address)?; + writeln!( + w, + "Validator List: {}", + &self.validator_list_storage_account + )?; + writeln!(w, "Manager: {}", &self.manager)?; + writeln!(w, "Staker: {}", &self.staker)?; + writeln!(w, "Depositor: {}", &self.stake_deposit_authority)?; + writeln!( + w, + "SOL Deposit Authority: {}", + &self + .sol_deposit_authority + .as_ref() + .unwrap_or(&"None".to_string()) + )?; + writeln!( + w, + "SOL Withdraw Authority: {}", + &self + .sol_withdraw_authority + .as_ref() + .unwrap_or(&"None".to_string()) + )?; + writeln!(w, "Withdraw Authority: {}", &self.pool_withdraw_authority)?; + writeln!(w, "Pool Token Mint: {}", &self.pool_mint)?; + writeln!(w, "Fee Account: {}", &self.manager_fee_account)?; + match &self.preferred_deposit_validator_vote_address { + None => {} + Some(s) => { + writeln!(w, "Preferred Deposit Validator: {}", s)?; + } + } + match &self.preferred_withdraw_validator_vote_address { + None => {} + Some(s) => { + writeln!(w, "Preferred Withdraw Validator: {}", s)?; + } + } + writeln!(w, "Epoch Fee: {} of epoch rewards", &self.epoch_fee)?; + if let Some(next_epoch_fee) = &self.next_epoch_fee { + writeln!(w, "Next Epoch Fee: {} of epoch rewards", next_epoch_fee)?; + } + writeln!( + w, + "Stake Withdrawal Fee: {} of withdrawal amount", + &self.stake_withdrawal_fee + )?; + if let Some(next_stake_withdrawal_fee) = &self.next_stake_withdrawal_fee { + writeln!( + w, + "Next Stake Withdrawal Fee: {} of withdrawal amount", + next_stake_withdrawal_fee + )?; + } + writeln!( + w, + "SOL Withdrawal Fee: {} of withdrawal amount", + &self.sol_withdrawal_fee + )?; + if let Some(next_sol_withdrawal_fee) = &self.next_sol_withdrawal_fee { + writeln!( + w, + "Next SOL Withdrawal Fee: {} of withdrawal amount", + next_sol_withdrawal_fee + )?; + } + writeln!( + w, + "Stake Deposit Fee: {} of deposit amount", + &self.stake_deposit_fee + )?; + writeln!( + w, + "SOL Deposit Fee: {} of deposit amount", + &self.sol_deposit_fee + )?; + writeln!( + w, + "Stake Deposit Referral Fee: {}% of Stake Deposit Fee", + &self.stake_referral_fee + )?; + writeln!( + w, + "SOL Deposit Referral Fee: {}% of SOL Deposit Fee", + &self.sol_referral_fee + )?; + writeln!(w)?; + + match &self.details { + None => {} + Some(details) => { + VerboseDisplay::write_str(details, w)?; + } + } + Ok(()) + } +} + +impl Display for CliStakePool { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f, "Stake Pool: {}", &self.address)?; + writeln!( + f, + "Validator List: {}", + &self.validator_list_storage_account + )?; + writeln!(f, "Pool Token Mint: {}", &self.pool_mint)?; + match &self.preferred_deposit_validator_vote_address { + None => {} + Some(s) => { + writeln!(f, "Preferred Deposit Validator: {}", s)?; + } + } + match &self.preferred_withdraw_validator_vote_address { + None => {} + Some(s) => { + writeln!(f, "Preferred Withdraw Validator: {}", s)?; + } + } + writeln!(f, "Epoch Fee: {} of epoch rewards", &self.epoch_fee)?; + writeln!( + f, + "Stake Withdrawal Fee: {} of withdrawal amount", + &self.stake_withdrawal_fee + )?; + writeln!( + f, + "SOL Withdrawal Fee: {} of withdrawal amount", + &self.sol_withdrawal_fee + )?; + writeln!( + f, + "Stake Deposit Fee: {} of deposit amount", + &self.stake_deposit_fee + )?; + writeln!( + f, + "SOL Deposit Fee: {} of deposit amount", + &self.sol_deposit_fee + )?; + writeln!( + f, + "Stake Deposit Referral Fee: {}% of Stake Deposit Fee", + &self.stake_referral_fee + )?; + writeln!( + f, + "SOL Deposit Referral Fee: {}% of SOL Deposit Fee", + &self.sol_referral_fee + )?; + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePoolDetails { + pub reserve_stake_account_address: String, + pub reserve_stake_lamports: u64, + pub minimum_reserve_stake_balance: u64, + pub stake_accounts: Vec, + pub total_lamports: u64, + pub total_pool_tokens: f64, + pub current_number_of_validators: u32, + pub max_number_of_validators: u32, + pub update_required: bool, +} + +impl Display for CliStakePoolDetails { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!( + f, + "Reserve Account: {}\tAvailable Balance: {}", + &self.reserve_stake_account_address, + Sol(self.reserve_stake_lamports - self.minimum_reserve_stake_balance), + )?; + for stake_account in &self.stake_accounts { + writeln!( + f, + "Vote Account: {}\tBalance: {}\tLast Update Epoch: {}", + stake_account.vote_account_address, + Sol(stake_account.validator_lamports), + stake_account.validator_last_update_epoch, + )?; + } + writeln!( + f, + "Total Pool Stake: {} {}", + Sol(self.total_lamports), + if self.update_required { + " [UPDATE REQUIRED]" + } else { + "" + }, + )?; + writeln!(f, "Total Pool Tokens: {}", &self.total_pool_tokens,)?; + writeln!( + f, + "Current Number of Validators: {}", + &self.current_number_of_validators, + )?; + writeln!( + f, + "Max Number of Validators: {}", + &self.max_number_of_validators, + )?; + Ok(()) + } +} + +impl QuietDisplay for CliStakePoolDetails {} +impl VerboseDisplay for CliStakePoolDetails { + fn write_str(&self, w: &mut dyn Write) -> Result { + writeln!(w, "Stake Accounts")?; + writeln!(w, "--------------")?; + writeln!( + w, + "Reserve Account: {}\tAvailable Balance: {}", + &self.reserve_stake_account_address, + Sol(self.reserve_stake_lamports - self.minimum_reserve_stake_balance), + )?; + for stake_account in &self.stake_accounts { + writeln!( + w, + "Vote Account: {}\tStake Account: {}\tActive Balance: {}\tTransient Stake Account: {}\tTransient Balance: {}\tLast Update Epoch: {}{}", + stake_account.vote_account_address, + stake_account.stake_account_address, + Sol(stake_account.validator_active_stake_lamports), + stake_account.validator_transient_stake_account_address, + Sol(stake_account.validator_transient_stake_lamports), + stake_account.validator_last_update_epoch, + if stake_account.update_required { + " [UPDATE REQUIRED]" + } else { + "" + }, + )?; + } + writeln!( + w, + "Total Pool Stake: {} {}", + Sol(self.total_lamports), + if self.update_required { + " [UPDATE REQUIRED]" + } else { + "" + }, + )?; + writeln!(w, "Total Pool Tokens: {}", &self.total_pool_tokens,)?; + writeln!( + w, + "Current Number of Validators: {}", + &self.current_number_of_validators, + )?; + writeln!( + w, + "Max Number of Validators: {}", + &self.max_number_of_validators, + )?; + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePoolStakeAccountInfo { + pub vote_account_address: String, + pub stake_account_address: String, + pub validator_active_stake_lamports: u64, + pub validator_last_update_epoch: u64, + pub validator_lamports: u64, + pub validator_transient_stake_account_address: String, + pub validator_transient_stake_lamports: u64, + pub update_required: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePoolValidator { + pub active_stake_lamports: u64, + pub transient_stake_lamports: u64, + pub last_update_epoch: u64, + pub transient_seed_suffix: u64, + pub unused: u32, + pub validator_seed_suffix: u32, + pub status: CliStakePoolValidatorStakeStatus, + pub vote_account_address: String, +} + +impl From for CliStakePoolValidator { + fn from(v: ValidatorStakeInfo) -> Self { + Self { + active_stake_lamports: v.active_stake_lamports.into(), + transient_stake_lamports: v.transient_stake_lamports.into(), + last_update_epoch: v.last_update_epoch.into(), + transient_seed_suffix: v.transient_seed_suffix.into(), + unused: v.unused.into(), + validator_seed_suffix: v.validator_seed_suffix.into(), + status: CliStakePoolValidatorStakeStatus::from(v.status), + vote_account_address: v.vote_account_address.to_string(), + } + } +} + +impl From for CliStakePoolValidatorStakeStatus { + fn from(s: PodStakeStatus) -> CliStakePoolValidatorStakeStatus { + let s = StakeStatus::try_from(s).unwrap(); + match s { + StakeStatus::Active => CliStakePoolValidatorStakeStatus::Active, + StakeStatus::DeactivatingTransient => { + CliStakePoolValidatorStakeStatus::DeactivatingTransient + } + StakeStatus::ReadyForRemoval => CliStakePoolValidatorStakeStatus::ReadyForRemoval, + StakeStatus::DeactivatingValidator => { + CliStakePoolValidatorStakeStatus::DeactivatingValidator + } + StakeStatus::DeactivatingAll => CliStakePoolValidatorStakeStatus::DeactivatingAll, + } + } +} + +#[derive(Serialize, Deserialize)] +pub(crate) enum CliStakePoolValidatorStakeStatus { + Active, + DeactivatingTransient, + ReadyForRemoval, + DeactivatingValidator, + DeactivatingAll, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliStakePoolLockup { + pub unix_timestamp: i64, + pub epoch: u64, + pub custodian: String, +} + +impl From for CliStakePoolLockup { + fn from(l: Lockup) -> Self { + Self { + unix_timestamp: l.unix_timestamp, + epoch: l.epoch, + custodian: l.custodian.to_string(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CliStakePoolFee { + pub denominator: u64, + pub numerator: u64, +} + +impl Display for CliStakePoolFee { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(f, "{}/{}", &self.numerator, &self.denominator) + } +} + +impl From for CliStakePoolFee { + fn from(f: Fee) -> Self { + Self { + denominator: f.denominator, + numerator: f.numerator, + } + } +} + +impl From<(Pubkey, StakePool, ValidatorList, Pubkey)> for CliStakePool { + fn from(s: (Pubkey, StakePool, ValidatorList, Pubkey)) -> Self { + let (address, stake_pool, validator_list, pool_withdraw_authority) = s; + Self { + address: address.to_string(), + pool_withdraw_authority: pool_withdraw_authority.to_string(), + manager: stake_pool.manager.to_string(), + staker: stake_pool.staker.to_string(), + stake_deposit_authority: stake_pool.stake_deposit_authority.to_string(), + stake_withdraw_bump_seed: stake_pool.stake_withdraw_bump_seed, + max_validators: validator_list.header.max_validators, + validator_list: validator_list + .validators + .into_iter() + .map(CliStakePoolValidator::from) + .collect(), + validator_list_storage_account: stake_pool.validator_list.to_string(), + reserve_stake: stake_pool.reserve_stake.to_string(), + pool_mint: stake_pool.pool_mint.to_string(), + manager_fee_account: stake_pool.manager_fee_account.to_string(), + token_program_id: stake_pool.token_program_id.to_string(), + total_lamports: stake_pool.total_lamports, + pool_token_supply: stake_pool.pool_token_supply, + last_update_epoch: stake_pool.last_update_epoch, + lockup: CliStakePoolLockup::from(stake_pool.lockup), + epoch_fee: CliStakePoolFee::from(stake_pool.epoch_fee), + next_epoch_fee: Option::::from(stake_pool.next_epoch_fee) + .map(CliStakePoolFee::from), + preferred_deposit_validator_vote_address: stake_pool + .preferred_deposit_validator_vote_address + .map(|x| x.to_string()), + preferred_withdraw_validator_vote_address: stake_pool + .preferred_withdraw_validator_vote_address + .map(|x| x.to_string()), + stake_deposit_fee: CliStakePoolFee::from(stake_pool.stake_deposit_fee), + stake_withdrawal_fee: CliStakePoolFee::from(stake_pool.stake_withdrawal_fee), + next_stake_withdrawal_fee: Option::::from(stake_pool.next_stake_withdrawal_fee) + .map(CliStakePoolFee::from), + stake_referral_fee: stake_pool.stake_referral_fee, + sol_deposit_authority: stake_pool.sol_deposit_authority.map(|x| x.to_string()), + sol_deposit_fee: CliStakePoolFee::from(stake_pool.sol_deposit_fee), + sol_referral_fee: stake_pool.sol_referral_fee, + sol_withdraw_authority: stake_pool.sol_withdraw_authority.map(|x| x.to_string()), + sol_withdrawal_fee: CliStakePoolFee::from(stake_pool.sol_withdrawal_fee), + next_sol_withdrawal_fee: Option::::from(stake_pool.next_sol_withdrawal_fee) + .map(CliStakePoolFee::from), + last_epoch_pool_token_supply: stake_pool.last_epoch_pool_token_supply, + last_epoch_total_lamports: stake_pool.last_epoch_total_lamports, + details: None, + } + } +} diff --git a/clients/js-legacy/.eslintignore b/clients/js-legacy/.eslintignore new file mode 100644 index 00000000..58542507 --- /dev/null +++ b/clients/js-legacy/.eslintignore @@ -0,0 +1,4 @@ +dist +node_modules +.vscode +.idea diff --git a/clients/js-legacy/.eslintrc.js b/clients/js-legacy/.eslintrc.js new file mode 100644 index 00000000..63db7b84 --- /dev/null +++ b/clients/js-legacy/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + jest: true, + }, + extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], + plugins: ['@typescript-eslint/eslint-plugin'], + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + }, + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + }, +}; diff --git a/clients/js-legacy/.gitignore b/clients/js-legacy/.gitignore new file mode 100644 index 00000000..3764afb8 --- /dev/null +++ b/clients/js-legacy/.gitignore @@ -0,0 +1,22 @@ +# IDE & OS specific +.DS_Store +.idea +.vscode + +# Logs +logs +*.log + +# Dependencies +node_modules + +# Coverage +coverage +.nyc_output + +# Release +dist +dist.browser + +# TypeScript +declarations diff --git a/clients/js-legacy/.prettierrc.js b/clients/js-legacy/.prettierrc.js new file mode 100644 index 00000000..8446d684 --- /dev/null +++ b/clients/js-legacy/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + endOfLine: 'lf', + semi: true, +}; diff --git a/clients/js-legacy/README.md b/clients/js-legacy/README.md new file mode 100644 index 00000000..947ec739 --- /dev/null +++ b/clients/js-legacy/README.md @@ -0,0 +1,54 @@ +# TypeScript bindings for stake-pool program + +For use with both node.js and in-browser. + +## Installation + +``` +npm install +``` + +## Build and run + +In the `js` folder: + +``` +npm run build +``` + +The build is available at `dist/index.js` (or `dist.browser/index.iife.js` in the browser). + +## Browser bundle +```html + + + + + +``` + +## Test + +``` +npm test +``` + +## Usage + +### JavaScript +```javascript +const solanaStakePool = require('@solana/spl-stake-pool'); +console.log(solanaStakePool); +``` + +### ES6 +```javascript +import * as solanaStakePool from '@solana/spl-stake-pool'; +console.log(solanaStakePool); +``` + +### Browser bundle +```javascript +// `solanaStakePool` is provided in the global namespace by the script bundle. +console.log(solanaStakePool); +``` diff --git a/clients/js-legacy/package.json b/clients/js-legacy/package.json new file mode 100644 index 00000000..04288746 --- /dev/null +++ b/clients/js-legacy/package.json @@ -0,0 +1,90 @@ +{ + "name": "@solana/spl-stake-pool", + "version": "1.1.8", + "description": "SPL Stake Pool Program JS API", + "scripts": { + "build": "tsc && cross-env NODE_ENV=production rollup -c", + "build:program": "cargo build-sbf --manifest-path=../program/Cargo.toml", + "lint": "eslint --max-warnings 0 .", + "lint:fix": "eslint . --fix", + "test": "jest", + "clean": "rimraf ./dist" + }, + "keywords": [], + "contributors": [ + "Solana Labs Maintainers ", + "Lieu Zheng Hong", + "mFactory Team (https://mfactory.ch/)", + "SolBlaze (https://solblaze.org/)" + ], + "homepage": "https://solana.com", + "repository": { + "type": "git", + "url": "https://github.com/solana-labs/solana-program-library" + }, + "publishConfig": { + "access": "public" + }, + "browser": { + "./dist/index.cjs.js": "./dist/index.browser.cjs.js", + "./dist/index.esm.js": "./dist/index.browser.esm.js" + }, + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "browserslist": [ + "defaults", + "not IE 11", + "maintained node versions" + ], + "files": [ + "/dist", + "/src" + ], + "license": "ISC", + "dependencies": { + "@solana/buffer-layout": "^4.0.1", + "@solana/spl-token": "0.4.9", + "@solana/web3.js": "^1.95.5", + "bn.js": "^5.2.0", + "buffer": "^6.0.3", + "buffer-layout": "^1.2.2", + "superstruct": "^2.0.2" + }, + "devDependencies": { + "@rollup/plugin-alias": "^5.1.1", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-multi-entry": "^6.0.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.1", + "@types/bn.js": "^5.1.6", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.1", + "@types/node-fetch": "^2.6.12", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "@typescript-eslint/parser": "^8.4.0", + "cross-env": "^7.0.3", + "eslint": "^8.57.0", + "jest": "^29.0.0", + "rimraf": "^6.0.1", + "rollup": "^4.28.0", + "rollup-plugin-dts": "^6.1.1", + "ts-jest": "^29.2.5", + "typescript": "^5.7.2" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRegex": ".*\\.test\\.ts$", + "testEnvironment": "node" + } +} diff --git a/clients/js-legacy/rollup.config.mjs b/clients/js-legacy/rollup.config.mjs new file mode 100644 index 00000000..f1ceb7e4 --- /dev/null +++ b/clients/js-legacy/rollup.config.mjs @@ -0,0 +1,113 @@ +import typescript from '@rollup/plugin-typescript'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import nodeResolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; + +const extensions = ['.js', '.ts']; + +function generateConfig(configType, format) { + const browser = configType === 'browser'; + + const config = { + input: 'src/index.ts', + plugins: [ + commonjs(), + nodeResolve({ + browser, + dedupe: ['bn.js', 'buffer'], + extensions, + preferBuiltins: !browser, + }), + typescript(), + ], + onwarn: function (warning, rollupWarn) { + if (warning.code !== 'CIRCULAR_DEPENDENCY') { + rollupWarn(warning); + } + }, + treeshake: { + moduleSideEffects: false, + }, + }; + + if (browser) { + if (format === 'iife') { + config.external = ['http', 'https']; + + config.output = [ + { + file: 'dist/index.iife.js', + format: 'iife', + name: 'solanaStakePool', + sourcemap: true, + }, + { + file: 'dist/index.iife.min.js', + format: 'iife', + name: 'solanaStakePool', + sourcemap: true, + plugins: [terser({ mangle: false, compress: false })], + }, + ]; + } else { + config.output = [ + { + file: 'dist/index.browser.cjs.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/index.browser.esm.js', + format: 'es', + sourcemap: true, + }, + ]; + + // Prevent dependencies from being bundled + config.external = [ + '@solana/buffer-layout', + '@solana/spl-token', + '@solana/web3.js', + 'bn.js', + 'buffer', + 'buffer-layout', + ]; + } + + // TODO: Find a workaround to avoid resolving the following JSON file: + // `node_modules/secp256k1/node_modules/elliptic/package.json` + config.plugins.push(json()); + } else { + config.output = [ + { + file: 'dist/index.cjs.js', + format: 'cjs', + sourcemap: true, + }, + { + file: 'dist/index.esm.js', + format: 'es', + sourcemap: true, + }, + ]; + + // Prevent dependencies from being bundled + config.external = [ + '@solana/buffer-layout', + '@solana/spl-token', + '@solana/web3.js', + 'bn.js', + 'buffer', + 'buffer-layout', + ]; + } + + return config; +} + +export default [ + generateConfig('node'), + generateConfig('browser'), + generateConfig('browser', 'iife'), +]; diff --git a/clients/js-legacy/src/codecs.ts b/clients/js-legacy/src/codecs.ts new file mode 100644 index 00000000..0dbbde55 --- /dev/null +++ b/clients/js-legacy/src/codecs.ts @@ -0,0 +1,159 @@ +import { blob, Layout as LayoutCls, offset, seq, struct, u32, u8 } from 'buffer-layout'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +export interface Layout { + span: number; + property?: string; + + decode(b: Buffer, offset?: number): T; + + encode(src: T, b: Buffer, offset?: number): number; + + getSpan(b: Buffer, offset?: number): number; + + replicate(name: string): this; +} + +class BNLayout extends LayoutCls { + blob: Layout; + signed: boolean; + + constructor(span: number, signed: boolean, property?: string) { + super(span, property); + this.blob = blob(span); + this.signed = signed; + } + + decode(b: Buffer, offset = 0) { + const num = new BN(this.blob.decode(b, offset), 10, 'le'); + if (this.signed) { + return num.fromTwos(this.span * 8).clone(); + } + return num; + } + + encode(src: BN, b: Buffer, offset = 0) { + if (this.signed) { + src = src.toTwos(this.span * 8); + } + return this.blob.encode(src.toArrayLike(Buffer, 'le', this.span), b, offset); + } +} + +export function u64(property?: string): Layout { + return new BNLayout(8, false, property); +} + +class WrappedLayout extends LayoutCls { + layout: Layout; + decoder: (data: T) => U; + encoder: (src: U) => T; + + constructor( + layout: Layout, + decoder: (data: T) => U, + encoder: (src: U) => T, + property?: string, + ) { + super(layout.span, property); + this.layout = layout; + this.decoder = decoder; + this.encoder = encoder; + } + + decode(b: Buffer, offset?: number): U { + return this.decoder(this.layout.decode(b, offset)); + } + + encode(src: U, b: Buffer, offset?: number): number { + return this.layout.encode(this.encoder(src), b, offset); + } + + getSpan(b: Buffer, offset?: number): number { + return this.layout.getSpan(b, offset); + } +} + +export function publicKey(property?: string): Layout { + return new WrappedLayout( + blob(32), + (b: Buffer) => new PublicKey(b), + (key: PublicKey) => key.toBuffer(), + property, + ); +} + +class OptionLayout extends LayoutCls { + layout: Layout; + discriminator: Layout; + + constructor(layout: Layout, property?: string) { + super(-1, property); + this.layout = layout; + this.discriminator = u8(); + } + + encode(src: T | null, b: Buffer, offset = 0): number { + if (src === null || src === undefined) { + return this.discriminator.encode(0, b, offset); + } + this.discriminator.encode(1, b, offset); + return this.layout.encode(src, b, offset + 1) + 1; + } + + decode(b: Buffer, offset = 0): T | null { + const discriminator = this.discriminator.decode(b, offset); + if (discriminator === 0) { + return null; + } else if (discriminator === 1) { + return this.layout.decode(b, offset + 1); + } + throw new Error('Invalid option ' + this.property); + } + + getSpan(b: Buffer, offset = 0): number { + const discriminator = this.discriminator.decode(b, offset); + if (discriminator === 0) { + return 1; + } else if (discriminator === 1) { + return this.layout.getSpan(b, offset + 1) + 1; + } + throw new Error('Invalid option ' + this.property); + } +} + +export function option(layout: Layout, property?: string): Layout { + return new OptionLayout(layout, property); +} + +export function bool(property?: string): Layout { + return new WrappedLayout(u8(), decodeBool, encodeBool, property); +} + +function decodeBool(value: number): boolean { + if (value === 0) { + return false; + } else if (value === 1) { + return true; + } + throw new Error('Invalid bool: ' + value); +} + +function encodeBool(value: boolean): number { + return value ? 1 : 0; +} + +export function vec(elementLayout: Layout, property?: string): Layout { + const length = u32('length'); + const layout: Layout<{ values: T[] }> = struct([ + length, + seq(elementLayout, offset(length, -length.span), 'values'), + ]); + return new WrappedLayout( + layout, + ({ values }) => values, + (values) => ({ values }), + property, + ); +} diff --git a/clients/js-legacy/src/constants.ts b/clients/js-legacy/src/constants.ts new file mode 100644 index 00000000..d0f24ffa --- /dev/null +++ b/clients/js-legacy/src/constants.ts @@ -0,0 +1,24 @@ +import { Buffer } from 'buffer'; +import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; + +// Public key that identifies the metadata program. +export const METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); +export const METADATA_MAX_NAME_LENGTH = 32; +export const METADATA_MAX_SYMBOL_LENGTH = 10; +export const METADATA_MAX_URI_LENGTH = 200; + +// Public key that identifies the SPL Stake Pool program. +export const STAKE_POOL_PROGRAM_ID = new PublicKey('SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy'); + +// Maximum number of validators to update during UpdateValidatorListBalance. +export const MAX_VALIDATORS_TO_UPDATE = 5; + +// Seed for ephemeral stake account +export const EPHEMERAL_STAKE_SEED_PREFIX = Buffer.from('ephemeral'); + +// Seed used to derive transient stake accounts. +export const TRANSIENT_STAKE_SEED_PREFIX = Buffer.from('transient'); + +// Minimum amount of staked SOL required in a validator stake account to allow +// for merges without a mismatch on credits observed +export const MINIMUM_ACTIVE_STAKE = LAMPORTS_PER_SOL; diff --git a/clients/js-legacy/src/index.ts b/clients/js-legacy/src/index.ts new file mode 100644 index 00000000..41414eac --- /dev/null +++ b/clients/js-legacy/src/index.ts @@ -0,0 +1,1228 @@ +import { + AccountInfo, + Connection, + Keypair, + PublicKey, + Signer, + StakeAuthorizationLayout, + StakeProgram, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { + createApproveInstruction, + createAssociatedTokenAccountIdempotentInstruction, + getAccount, + getAssociatedTokenAddressSync, +} from '@solana/spl-token'; +import { + ValidatorAccount, + arrayChunk, + calcLamportsWithdrawAmount, + findStakeProgramAddress, + findTransientStakeProgramAddress, + findWithdrawAuthorityProgramAddress, + getValidatorListAccount, + newStakeAccount, + prepareWithdrawAccounts, + lamportsToSol, + solToLamports, + findEphemeralStakeProgramAddress, + findMetadataAddress, +} from './utils'; +import { StakePoolInstruction } from './instructions'; +import { + StakeAccount, + StakePool, + StakePoolLayout, + ValidatorList, + ValidatorListLayout, + ValidatorStakeInfo, +} from './layouts'; +import { MAX_VALIDATORS_TO_UPDATE, MINIMUM_ACTIVE_STAKE, STAKE_POOL_PROGRAM_ID } from './constants'; +import { create } from 'superstruct'; +import BN from 'bn.js'; + +export type { StakePool, AccountType, ValidatorList, ValidatorStakeInfo } from './layouts'; +export { STAKE_POOL_PROGRAM_ID } from './constants'; +export * from './instructions'; +export { StakePoolLayout, ValidatorListLayout, ValidatorStakeInfoLayout } from './layouts'; + +export interface ValidatorListAccount { + pubkey: PublicKey; + account: AccountInfo; +} + +export interface StakePoolAccount { + pubkey: PublicKey; + account: AccountInfo; +} + +export interface WithdrawAccount { + stakeAddress: PublicKey; + voteAddress?: PublicKey; + poolAmount: BN; +} + +/** + * Wrapper class for a stake pool. + * Each stake pool has a stake pool account and a validator list account. + */ +export interface StakePoolAccounts { + stakePool: StakePoolAccount | undefined; + validatorList: ValidatorListAccount | undefined; +} + +/** + * Retrieves and deserializes a StakePool account using a web3js connection and the stake pool address. + * @param connection: An active web3js connection. + * @param stakePoolAddress: The public key (address) of the stake pool account. + */ +export async function getStakePoolAccount( + connection: Connection, + stakePoolAddress: PublicKey, +): Promise { + const account = await connection.getAccountInfo(stakePoolAddress); + + if (!account) { + throw new Error('Invalid stake pool account'); + } + + return { + pubkey: stakePoolAddress, + account: { + data: StakePoolLayout.decode(account.data), + executable: account.executable, + lamports: account.lamports, + owner: account.owner, + }, + }; +} + +/** + * Retrieves and deserializes a Stake account using a web3js connection and the stake address. + * @param connection: An active web3js connection. + * @param stakeAccount: The public key (address) of the stake account. + */ +export async function getStakeAccount( + connection: Connection, + stakeAccount: PublicKey, +): Promise { + const result = (await connection.getParsedAccountInfo(stakeAccount)).value; + if (!result || !('parsed' in result.data)) { + throw new Error('Invalid stake account'); + } + const program = result.data.program; + if (program != 'stake') { + throw new Error('Not a stake account'); + } + const parsed = create(result.data.parsed, StakeAccount); + + return parsed; +} + +/** + * Retrieves all StakePool and ValidatorList accounts that are running a particular StakePool program. + * @param connection: An active web3js connection. + * @param stakePoolProgramAddress: The public key (address) of the StakePool program. + */ +export async function getStakePoolAccounts( + connection: Connection, + stakePoolProgramAddress: PublicKey, +): Promise<(StakePoolAccount | ValidatorListAccount | undefined)[] | undefined> { + const response = await connection.getProgramAccounts(stakePoolProgramAddress); + + return response + .map((a) => { + try { + if (a.account.data.readUInt8() === 1) { + const data = StakePoolLayout.decode(a.account.data); + return { + pubkey: a.pubkey, + account: { + data, + executable: a.account.executable, + lamports: a.account.lamports, + owner: a.account.owner, + }, + }; + } else if (a.account.data.readUInt8() === 2) { + const data = ValidatorListLayout.decode(a.account.data); + return { + pubkey: a.pubkey, + account: { + data, + executable: a.account.executable, + lamports: a.account.lamports, + owner: a.account.owner, + }, + }; + } else { + console.error( + `Could not decode. StakePoolAccount Enum is ${a.account.data.readUInt8()}, expected 1 or 2!`, + ); + return undefined; + } + } catch (error) { + console.error('Could not decode account. Error:', error); + return undefined; + } + }) + .filter((a) => a !== undefined); +} + +/** + * Creates instructions required to deposit stake to stake pool. + */ +export async function depositStake( + connection: Connection, + stakePoolAddress: PublicKey, + authorizedPubkey: PublicKey, + validatorVote: PublicKey, + depositStake: PublicKey, + poolTokenReceiverAccount?: PublicKey, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorVote, + stakePoolAddress, + ); + + const instructions: TransactionInstruction[] = []; + const signers: Signer[] = []; + + const poolMint = stakePool.account.data.poolMint; + + // Create token account if not specified + if (!poolTokenReceiverAccount) { + const associatedAddress = getAssociatedTokenAddressSync(poolMint, authorizedPubkey); + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + authorizedPubkey, + associatedAddress, + authorizedPubkey, + poolMint, + ), + ); + poolTokenReceiverAccount = associatedAddress; + } + + instructions.push( + ...StakeProgram.authorize({ + stakePubkey: depositStake, + authorizedPubkey, + newAuthorizedPubkey: stakePool.account.data.stakeDepositAuthority, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + }).instructions, + ); + + instructions.push( + ...StakeProgram.authorize({ + stakePubkey: depositStake, + authorizedPubkey, + newAuthorizedPubkey: stakePool.account.data.stakeDepositAuthority, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + }).instructions, + ); + + instructions.push( + StakePoolInstruction.depositStake({ + stakePool: stakePoolAddress, + validatorList: stakePool.account.data.validatorList, + depositAuthority: stakePool.account.data.stakeDepositAuthority, + reserveStake: stakePool.account.data.reserveStake, + managerFeeAccount: stakePool.account.data.managerFeeAccount, + referralPoolAccount: poolTokenReceiverAccount, + destinationPoolAccount: poolTokenReceiverAccount, + withdrawAuthority, + depositStake, + validatorStake, + poolMint, + }), + ); + + return { + instructions, + signers, + }; +} + +/** + * Creates instructions required to deposit sol to stake pool. + */ +export async function depositSol( + connection: Connection, + stakePoolAddress: PublicKey, + from: PublicKey, + lamports: number, + destinationTokenAccount?: PublicKey, + referrerTokenAccount?: PublicKey, + depositAuthority?: PublicKey, +) { + const fromBalance = await connection.getBalance(from, 'confirmed'); + if (fromBalance < lamports) { + throw new Error( + `Not enough SOL to deposit into pool. Maximum deposit amount is ${lamportsToSol( + fromBalance, + )} SOL.`, + ); + } + + const stakePoolAccount = await getStakePoolAccount(connection, stakePoolAddress); + const stakePool = stakePoolAccount.account.data; + + // Ephemeral SOL account just to do the transfer + const userSolTransfer = new Keypair(); + const signers: Signer[] = [userSolTransfer]; + const instructions: TransactionInstruction[] = []; + + // Create the ephemeral SOL account + instructions.push( + SystemProgram.transfer({ + fromPubkey: from, + toPubkey: userSolTransfer.publicKey, + lamports, + }), + ); + + // Create token account if not specified + if (!destinationTokenAccount) { + const associatedAddress = getAssociatedTokenAddressSync(stakePool.poolMint, from); + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + from, + associatedAddress, + from, + stakePool.poolMint, + ), + ); + destinationTokenAccount = associatedAddress; + } + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + instructions.push( + StakePoolInstruction.depositSol({ + stakePool: stakePoolAddress, + reserveStake: stakePool.reserveStake, + fundingAccount: userSolTransfer.publicKey, + destinationPoolAccount: destinationTokenAccount, + managerFeeAccount: stakePool.managerFeeAccount, + referralPoolAccount: referrerTokenAccount ?? destinationTokenAccount, + poolMint: stakePool.poolMint, + lamports, + withdrawAuthority, + depositAuthority, + }), + ); + + return { + instructions, + signers, + }; +} + +/** + * Creates instructions required to withdraw stake from a stake pool. + */ +export async function withdrawStake( + connection: Connection, + stakePoolAddress: PublicKey, + tokenOwner: PublicKey, + amount: number, + useReserve = false, + voteAccountAddress?: PublicKey, + stakeReceiver?: PublicKey, + poolTokenAccount?: PublicKey, + validatorComparator?: (_a: ValidatorAccount, _b: ValidatorAccount) => number, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + const poolAmount = new BN(solToLamports(amount)); + + if (!poolTokenAccount) { + poolTokenAccount = getAssociatedTokenAddressSync(stakePool.account.data.poolMint, tokenOwner); + } + + const tokenAccount = await getAccount(connection, poolTokenAccount); + + // Check withdrawFrom balance + if (tokenAccount.amount < poolAmount.toNumber()) { + throw new Error( + `Not enough token balance to withdraw ${lamportsToSol(poolAmount)} pool tokens. + Maximum withdraw amount is ${lamportsToSol(tokenAccount.amount)} pool tokens.`, + ); + } + + const stakeAccountRentExemption = await connection.getMinimumBalanceForRentExemption( + StakeProgram.space, + ); + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + let stakeReceiverAccount = null; + if (stakeReceiver) { + stakeReceiverAccount = await getStakeAccount(connection, stakeReceiver); + } + + const withdrawAccounts: WithdrawAccount[] = []; + + if (useReserve) { + withdrawAccounts.push({ + stakeAddress: stakePool.account.data.reserveStake, + voteAddress: undefined, + poolAmount, + }); + } else if (stakeReceiverAccount && stakeReceiverAccount?.type == 'delegated') { + const voteAccount = stakeReceiverAccount.info?.stake?.delegation.voter; + if (!voteAccount) throw new Error(`Invalid stake receiver ${stakeReceiver} delegation`); + const validatorListAccount = await connection.getAccountInfo( + stakePool.account.data.validatorList, + ); + const validatorList = ValidatorListLayout.decode(validatorListAccount?.data) as ValidatorList; + const isValidVoter = validatorList.validators.find((val) => + val.voteAccountAddress.equals(voteAccount), + ); + if (voteAccountAddress && voteAccountAddress !== voteAccount) { + throw new Error(`Provided withdrawal vote account ${voteAccountAddress} does not match delegation on stake receiver account ${voteAccount}, + remove this flag or provide a different stake account delegated to ${voteAccountAddress}`); + } + if (isValidVoter) { + const stakeAccountAddress = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + voteAccount, + stakePoolAddress, + ); + + const stakeAccount = await connection.getAccountInfo(stakeAccountAddress); + if (!stakeAccount) { + throw new Error(`Preferred withdraw valdator's stake account is invalid`); + } + + const availableForWithdrawal = calcLamportsWithdrawAmount( + stakePool.account.data, + new BN(stakeAccount.lamports - MINIMUM_ACTIVE_STAKE - stakeAccountRentExemption), + ); + + if (availableForWithdrawal.lt(poolAmount)) { + throw new Error( + `Not enough lamports available for withdrawal from ${stakeAccountAddress}, + ${poolAmount} asked, ${availableForWithdrawal} available.`, + ); + } + withdrawAccounts.push({ + stakeAddress: stakeAccountAddress, + voteAddress: voteAccount, + poolAmount, + }); + } else { + throw new Error( + `Provided stake account is delegated to a vote account ${voteAccount} which does not exist in the stake pool`, + ); + } + } else if (voteAccountAddress) { + const stakeAccountAddress = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + voteAccountAddress, + stakePoolAddress, + ); + const stakeAccount = await connection.getAccountInfo(stakeAccountAddress); + if (!stakeAccount) { + throw new Error('Invalid Stake Account'); + } + + const availableLamports = new BN( + stakeAccount.lamports - MINIMUM_ACTIVE_STAKE - stakeAccountRentExemption, + ); + if (availableLamports.lt(new BN(0))) { + throw new Error('Invalid Stake Account'); + } + const availableForWithdrawal = calcLamportsWithdrawAmount( + stakePool.account.data, + availableLamports, + ); + + if (availableForWithdrawal.lt(poolAmount)) { + // noinspection ExceptionCaughtLocallyJS + throw new Error( + `Not enough lamports available for withdrawal from ${stakeAccountAddress}, + ${poolAmount} asked, ${availableForWithdrawal} available.`, + ); + } + withdrawAccounts.push({ + stakeAddress: stakeAccountAddress, + voteAddress: voteAccountAddress, + poolAmount, + }); + } else { + // Get the list of accounts to withdraw from + withdrawAccounts.push( + ...(await prepareWithdrawAccounts( + connection, + stakePool.account.data, + stakePoolAddress, + poolAmount, + validatorComparator, + poolTokenAccount.equals(stakePool.account.data.managerFeeAccount), + )), + ); + } + + // Construct transaction to withdraw from withdrawAccounts account list + const instructions: TransactionInstruction[] = []; + const userTransferAuthority = Keypair.generate(); + + const signers: Signer[] = [userTransferAuthority]; + + instructions.push( + createApproveInstruction( + poolTokenAccount, + userTransferAuthority.publicKey, + tokenOwner, + poolAmount.toNumber(), + ), + ); + + let totalRentFreeBalances = 0; + + // Max 5 accounts to prevent an error: "Transaction too large" + const maxWithdrawAccounts = 5; + let i = 0; + + // Go through prepared accounts and withdraw/claim them + for (const withdrawAccount of withdrawAccounts) { + if (i > maxWithdrawAccounts) { + break; + } + // Convert pool tokens amount to lamports + const solWithdrawAmount = calcLamportsWithdrawAmount( + stakePool.account.data, + withdrawAccount.poolAmount, + ); + + let infoMsg = `Withdrawing â—Ž${solWithdrawAmount}, + from stake account ${withdrawAccount.stakeAddress?.toBase58()}`; + + if (withdrawAccount.voteAddress) { + infoMsg = `${infoMsg}, delegated to ${withdrawAccount.voteAddress?.toBase58()}`; + } + + console.info(infoMsg); + let stakeToReceive; + + if (!stakeReceiver || (stakeReceiverAccount && stakeReceiverAccount.type === 'delegated')) { + const stakeKeypair = newStakeAccount(tokenOwner, instructions, stakeAccountRentExemption); + signers.push(stakeKeypair); + totalRentFreeBalances += stakeAccountRentExemption; + stakeToReceive = stakeKeypair.publicKey; + } else { + stakeToReceive = stakeReceiver; + } + + instructions.push( + StakePoolInstruction.withdrawStake({ + stakePool: stakePoolAddress, + validatorList: stakePool.account.data.validatorList, + validatorStake: withdrawAccount.stakeAddress, + destinationStake: stakeToReceive, + destinationStakeAuthority: tokenOwner, + sourceTransferAuthority: userTransferAuthority.publicKey, + sourcePoolAccount: poolTokenAccount, + managerFeeAccount: stakePool.account.data.managerFeeAccount, + poolMint: stakePool.account.data.poolMint, + poolTokens: withdrawAccount.poolAmount.toNumber(), + withdrawAuthority, + }), + ); + i++; + } + if (stakeReceiver && stakeReceiverAccount && stakeReceiverAccount.type === 'delegated') { + signers.forEach((newStakeKeypair) => { + instructions.concat( + StakeProgram.merge({ + stakePubkey: stakeReceiver, + sourceStakePubKey: newStakeKeypair.publicKey, + authorizedPubkey: tokenOwner, + }).instructions, + ); + }); + } + + return { + instructions, + signers, + stakeReceiver, + totalRentFreeBalances, + }; +} + +/** + * Creates instructions required to withdraw SOL directly from a stake pool. + */ +export async function withdrawSol( + connection: Connection, + stakePoolAddress: PublicKey, + tokenOwner: PublicKey, + solReceiver: PublicKey, + amount: number, + solWithdrawAuthority?: PublicKey, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + const poolAmount = solToLamports(amount); + + const poolTokenAccount = getAssociatedTokenAddressSync( + stakePool.account.data.poolMint, + tokenOwner, + ); + + const tokenAccount = await getAccount(connection, poolTokenAccount); + + // Check withdrawFrom balance + if (tokenAccount.amount < poolAmount) { + throw new Error( + `Not enough token balance to withdraw ${lamportsToSol(poolAmount)} pool tokens. + Maximum withdraw amount is ${lamportsToSol(tokenAccount.amount)} pool tokens.`, + ); + } + + // Construct transaction to withdraw from withdrawAccounts account list + const instructions: TransactionInstruction[] = []; + const userTransferAuthority = Keypair.generate(); + const signers: Signer[] = [userTransferAuthority]; + + instructions.push( + createApproveInstruction( + poolTokenAccount, + userTransferAuthority.publicKey, + tokenOwner, + poolAmount, + ), + ); + + const poolWithdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + if (solWithdrawAuthority) { + const expectedSolWithdrawAuthority = stakePool.account.data.solWithdrawAuthority; + if (!expectedSolWithdrawAuthority) { + throw new Error('SOL withdraw authority specified in arguments but stake pool has none'); + } + if (solWithdrawAuthority.toBase58() != expectedSolWithdrawAuthority.toBase58()) { + throw new Error( + `Invalid deposit withdraw specified, expected ${expectedSolWithdrawAuthority.toBase58()}, received ${solWithdrawAuthority.toBase58()}`, + ); + } + } + + const withdrawTransaction = StakePoolInstruction.withdrawSol({ + stakePool: stakePoolAddress, + withdrawAuthority: poolWithdrawAuthority, + reserveStake: stakePool.account.data.reserveStake, + sourcePoolAccount: poolTokenAccount, + sourceTransferAuthority: userTransferAuthority.publicKey, + destinationSystemAccount: solReceiver, + managerFeeAccount: stakePool.account.data.managerFeeAccount, + poolMint: stakePool.account.data.poolMint, + poolTokens: poolAmount, + solWithdrawAuthority, + }); + + instructions.push(withdrawTransaction); + + return { + instructions, + signers, + }; +} + +export async function addValidatorToPool( + connection: Connection, + stakePoolAddress: PublicKey, + validatorVote: PublicKey, + seed?: number, +) { + const stakePoolAccount = await getStakePoolAccount(connection, stakePoolAddress); + const stakePool = stakePoolAccount.account.data; + const { reserveStake, staker, validatorList } = stakePool; + + const validatorListAccount = await getValidatorListAccount(connection, validatorList); + + const validatorInfo = validatorListAccount.account.data.validators.find( + (v) => v.voteAccountAddress.toBase58() == validatorVote.toBase58(), + ); + + if (validatorInfo) { + throw new Error('Vote account is already in validator list'); + } + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorVote, + stakePoolAddress, + seed, + ); + + const instructions: TransactionInstruction[] = [ + StakePoolInstruction.addValidatorToPool({ + stakePool: stakePoolAddress, + staker: staker, + reserveStake: reserveStake, + withdrawAuthority: withdrawAuthority, + validatorList: validatorList, + validatorStake: validatorStake, + validatorVote: validatorVote, + }), + ]; + + return { + instructions, + }; +} + +export async function removeValidatorFromPool( + connection: Connection, + stakePoolAddress: PublicKey, + validatorVote: PublicKey, + seed?: number, +) { + const stakePoolAccount = await getStakePoolAccount(connection, stakePoolAddress); + const stakePool = stakePoolAccount.account.data; + const { staker, validatorList } = stakePool; + + const validatorListAccount = await getValidatorListAccount(connection, validatorList); + + const validatorInfo = validatorListAccount.account.data.validators.find( + (v) => v.voteAccountAddress.toBase58() == validatorVote.toBase58(), + ); + + if (!validatorInfo) { + throw new Error('Vote account is not already in validator list'); + } + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorVote, + stakePoolAddress, + seed, + ); + + const transientStakeSeed = validatorInfo.transientSeedSuffixStart; + + const transientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorInfo.voteAccountAddress, + stakePoolAddress, + transientStakeSeed, + ); + + const instructions: TransactionInstruction[] = [ + StakePoolInstruction.removeValidatorFromPool({ + stakePool: stakePoolAddress, + staker: staker, + withdrawAuthority, + validatorList, + validatorStake, + transientStake, + }), + ]; + + return { + instructions, + }; +} + +/** + * Creates instructions required to increase validator stake. + */ +export async function increaseValidatorStake( + connection: Connection, + stakePoolAddress: PublicKey, + validatorVote: PublicKey, + lamports: number, + ephemeralStakeSeed?: number, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + + const validatorList = await getValidatorListAccount( + connection, + stakePool.account.data.validatorList, + ); + + const validatorInfo = validatorList.account.data.validators.find( + (v) => v.voteAccountAddress.toBase58() == validatorVote.toBase58(), + ); + + if (!validatorInfo) { + throw new Error('Vote account not found in validator list'); + } + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + // Bump transient seed suffix by one to avoid reuse when not using the increaseAdditionalStake instruction + const transientStakeSeed = + ephemeralStakeSeed == undefined + ? validatorInfo.transientSeedSuffixStart.addn(1) + : validatorInfo.transientSeedSuffixStart; + + const transientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorInfo.voteAccountAddress, + stakePoolAddress, + transientStakeSeed, + ); + + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorInfo.voteAccountAddress, + stakePoolAddress, + ); + + const instructions: TransactionInstruction[] = []; + + if (ephemeralStakeSeed != undefined) { + const ephemeralStake = await findEphemeralStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + new BN(ephemeralStakeSeed), + ); + instructions.push( + StakePoolInstruction.increaseAdditionalValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + transientStake, + validatorStake, + validatorVote, + lamports, + ephemeralStake, + ephemeralStakeSeed, + }), + ); + } else { + instructions.push( + StakePoolInstruction.increaseValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + transientStake, + validatorStake, + validatorVote, + lamports, + }), + ); + } + + return { + instructions, + }; +} + +/** + * Creates instructions required to decrease validator stake. + */ +export async function decreaseValidatorStake( + connection: Connection, + stakePoolAddress: PublicKey, + validatorVote: PublicKey, + lamports: number, + ephemeralStakeSeed?: number, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + const validatorList = await getValidatorListAccount( + connection, + stakePool.account.data.validatorList, + ); + + const validatorInfo = validatorList.account.data.validators.find( + (v) => v.voteAccountAddress.toBase58() == validatorVote.toBase58(), + ); + + if (!validatorInfo) { + throw new Error('Vote account not found in validator list'); + } + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorInfo.voteAccountAddress, + stakePoolAddress, + ); + + // Bump transient seed suffix by one to avoid reuse when not using the decreaseAdditionalStake instruction + const transientStakeSeed = + ephemeralStakeSeed == undefined + ? validatorInfo.transientSeedSuffixStart.addn(1) + : validatorInfo.transientSeedSuffixStart; + + const transientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorInfo.voteAccountAddress, + stakePoolAddress, + transientStakeSeed, + ); + + const instructions: TransactionInstruction[] = []; + + if (ephemeralStakeSeed != undefined) { + const ephemeralStake = await findEphemeralStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + new BN(ephemeralStakeSeed), + ); + instructions.push( + StakePoolInstruction.decreaseAdditionalValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + validatorStake, + transientStake, + lamports, + ephemeralStake, + ephemeralStakeSeed, + }), + ); + } else { + instructions.push( + StakePoolInstruction.decreaseValidatorStakeWithReserve({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + validatorStake, + transientStake, + lamports, + }), + ); + } + + return { + instructions, + }; +} + +/** + * Creates instructions required to completely update a stake pool after epoch change. + */ +export async function updateStakePool( + connection: Connection, + stakePool: StakePoolAccount, + noMerge = false, +) { + const stakePoolAddress = stakePool.pubkey; + + const validatorList = await getValidatorListAccount( + connection, + stakePool.account.data.validatorList, + ); + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const updateListInstructions: TransactionInstruction[] = []; + const instructions: TransactionInstruction[] = []; + + let startIndex = 0; + const validatorChunks: Array = arrayChunk( + validatorList.account.data.validators, + MAX_VALIDATORS_TO_UPDATE, + ); + + for (const validatorChunk of validatorChunks) { + const validatorAndTransientStakePairs: PublicKey[] = []; + + for (const validator of validatorChunk) { + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + ); + validatorAndTransientStakePairs.push(validatorStake); + + const transientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + validator.transientSeedSuffixStart, + ); + validatorAndTransientStakePairs.push(transientStake); + } + + updateListInstructions.push( + StakePoolInstruction.updateValidatorListBalance({ + stakePool: stakePoolAddress, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + validatorAndTransientStakePairs, + withdrawAuthority, + startIndex, + noMerge, + }), + ); + startIndex += MAX_VALIDATORS_TO_UPDATE; + } + + instructions.push( + StakePoolInstruction.updateStakePoolBalance({ + stakePool: stakePoolAddress, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + managerFeeAccount: stakePool.account.data.managerFeeAccount, + poolMint: stakePool.account.data.poolMint, + withdrawAuthority, + }), + ); + + instructions.push( + StakePoolInstruction.cleanupRemovedValidatorEntries({ + stakePool: stakePoolAddress, + validatorList: stakePool.account.data.validatorList, + }), + ); + + return { + updateListInstructions, + finalInstructions: instructions, + }; +} + +/** + * Retrieves detailed information about the StakePool. + */ +export async function stakePoolInfo(connection: Connection, stakePoolAddress: PublicKey) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + const reserveAccountStakeAddress = stakePool.account.data.reserveStake; + const totalLamports = stakePool.account.data.totalLamports; + const lastUpdateEpoch = stakePool.account.data.lastUpdateEpoch; + + const validatorList = await getValidatorListAccount( + connection, + stakePool.account.data.validatorList, + ); + + const maxNumberOfValidators = validatorList.account.data.maxValidators; + const currentNumberOfValidators = validatorList.account.data.validators.length; + + const epochInfo = await connection.getEpochInfo(); + const reserveStake = await connection.getAccountInfo(reserveAccountStakeAddress); + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const minimumReserveStakeBalance = await connection.getMinimumBalanceForRentExemption( + StakeProgram.space, + ); + + const stakeAccounts = await Promise.all( + validatorList.account.data.validators.map(async (validator) => { + const stakeAccountAddress = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + ); + const transientStakeAccountAddress = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + validator.transientSeedSuffixStart, + ); + const updateRequired = !validator.lastUpdateEpoch.eqn(epochInfo.epoch); + return { + voteAccountAddress: validator.voteAccountAddress.toBase58(), + stakeAccountAddress: stakeAccountAddress.toBase58(), + validatorActiveStakeLamports: validator.activeStakeLamports.toString(), + validatorLastUpdateEpoch: validator.lastUpdateEpoch.toString(), + validatorLamports: validator.activeStakeLamports + .add(validator.transientStakeLamports) + .toString(), + validatorTransientStakeAccountAddress: transientStakeAccountAddress.toBase58(), + validatorTransientStakeLamports: validator.transientStakeLamports.toString(), + updateRequired, + }; + }), + ); + + const totalPoolTokens = lamportsToSol(stakePool.account.data.poolTokenSupply); + const updateRequired = !lastUpdateEpoch.eqn(epochInfo.epoch); + + return { + address: stakePoolAddress.toBase58(), + poolWithdrawAuthority: withdrawAuthority.toBase58(), + manager: stakePool.account.data.manager.toBase58(), + staker: stakePool.account.data.staker.toBase58(), + stakeDepositAuthority: stakePool.account.data.stakeDepositAuthority.toBase58(), + stakeWithdrawBumpSeed: stakePool.account.data.stakeWithdrawBumpSeed, + maxValidators: maxNumberOfValidators, + validatorList: validatorList.account.data.validators.map((validator) => { + return { + activeStakeLamports: validator.activeStakeLamports.toString(), + transientStakeLamports: validator.transientStakeLamports.toString(), + lastUpdateEpoch: validator.lastUpdateEpoch.toString(), + transientSeedSuffixStart: validator.transientSeedSuffixStart.toString(), + transientSeedSuffixEnd: validator.transientSeedSuffixEnd.toString(), + status: validator.status.toString(), + voteAccountAddress: validator.voteAccountAddress.toString(), + }; + }), // CliStakePoolValidator + validatorListStorageAccount: stakePool.account.data.validatorList.toBase58(), + reserveStake: stakePool.account.data.reserveStake.toBase58(), + poolMint: stakePool.account.data.poolMint.toBase58(), + managerFeeAccount: stakePool.account.data.managerFeeAccount.toBase58(), + tokenProgramId: stakePool.account.data.tokenProgramId.toBase58(), + totalLamports: stakePool.account.data.totalLamports.toString(), + poolTokenSupply: stakePool.account.data.poolTokenSupply.toString(), + lastUpdateEpoch: stakePool.account.data.lastUpdateEpoch.toString(), + lockup: stakePool.account.data.lockup, // pub lockup: CliStakePoolLockup + epochFee: stakePool.account.data.epochFee, + nextEpochFee: stakePool.account.data.nextEpochFee, + preferredDepositValidatorVoteAddress: + stakePool.account.data.preferredDepositValidatorVoteAddress, + preferredWithdrawValidatorVoteAddress: + stakePool.account.data.preferredWithdrawValidatorVoteAddress, + stakeDepositFee: stakePool.account.data.stakeDepositFee, + stakeWithdrawalFee: stakePool.account.data.stakeWithdrawalFee, + // CliStakePool the same + nextStakeWithdrawalFee: stakePool.account.data.nextStakeWithdrawalFee, + stakeReferralFee: stakePool.account.data.stakeReferralFee, + solDepositAuthority: stakePool.account.data.solDepositAuthority?.toBase58(), + solDepositFee: stakePool.account.data.solDepositFee, + solReferralFee: stakePool.account.data.solReferralFee, + solWithdrawAuthority: stakePool.account.data.solWithdrawAuthority?.toBase58(), + solWithdrawalFee: stakePool.account.data.solWithdrawalFee, + nextSolWithdrawalFee: stakePool.account.data.nextSolWithdrawalFee, + lastEpochPoolTokenSupply: stakePool.account.data.lastEpochPoolTokenSupply.toString(), + lastEpochTotalLamports: stakePool.account.data.lastEpochTotalLamports.toString(), + details: { + reserveStakeLamports: reserveStake?.lamports, + reserveAccountStakeAddress: reserveAccountStakeAddress.toBase58(), + minimumReserveStakeBalance, + stakeAccounts, + totalLamports, + totalPoolTokens, + currentNumberOfValidators, + maxNumberOfValidators, + updateRequired, + }, // CliStakePoolDetails + }; +} + +/** + * Creates instructions required to create pool token metadata. + */ +export async function createPoolTokenMetadata( + connection: Connection, + stakePoolAddress: PublicKey, + payer: PublicKey, + name: string, + symbol: string, + uri: string, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + const tokenMetadata = findMetadataAddress(stakePool.account.data.poolMint); + const manager = stakePool.account.data.manager; + + const instructions: TransactionInstruction[] = []; + instructions.push( + StakePoolInstruction.createTokenMetadata({ + stakePool: stakePoolAddress, + poolMint: stakePool.account.data.poolMint, + payer, + manager, + tokenMetadata, + withdrawAuthority, + name, + symbol, + uri, + }), + ); + + return { + instructions, + }; +} + +/** + * Creates instructions required to update pool token metadata. + */ +export async function updatePoolTokenMetadata( + connection: Connection, + stakePoolAddress: PublicKey, + name: string, + symbol: string, + uri: string, +) { + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + + const withdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const tokenMetadata = findMetadataAddress(stakePool.account.data.poolMint); + + const instructions: TransactionInstruction[] = []; + instructions.push( + StakePoolInstruction.updateTokenMetadata({ + stakePool: stakePoolAddress, + manager: stakePool.account.data.manager, + tokenMetadata, + withdrawAuthority, + name, + symbol, + uri, + }), + ); + + return { + instructions, + }; +} diff --git a/clients/js-legacy/src/instructions.ts b/clients/js-legacy/src/instructions.ts new file mode 100644 index 00000000..eba2f149 --- /dev/null +++ b/clients/js-legacy/src/instructions.ts @@ -0,0 +1,1095 @@ +import { + PublicKey, + STAKE_CONFIG_ID, + SYSVAR_CLOCK_PUBKEY, + SYSVAR_RENT_PUBKEY, + SYSVAR_STAKE_HISTORY_PUBKEY, + StakeProgram, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import * as BufferLayout from '@solana/buffer-layout'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { InstructionType, encodeData, decodeData } from './utils'; +import { + METADATA_MAX_NAME_LENGTH, + METADATA_MAX_SYMBOL_LENGTH, + METADATA_MAX_URI_LENGTH, + METADATA_PROGRAM_ID, + STAKE_POOL_PROGRAM_ID, +} from './constants'; + +/** + * An enumeration of valid StakePoolInstructionType's + */ +export type StakePoolInstructionType = + | 'IncreaseValidatorStake' + | 'DecreaseValidatorStake' + | 'UpdateValidatorListBalance' + | 'UpdateStakePoolBalance' + | 'CleanupRemovedValidatorEntries' + | 'DepositStake' + | 'DepositSol' + | 'WithdrawStake' + | 'WithdrawSol' + | 'IncreaseAdditionalValidatorStake' + | 'DecreaseAdditionalValidatorStake' + | 'DecreaseValidatorStakeWithReserve' + | 'Redelegate' + | 'AddValidatorToPool' + | 'RemoveValidatorFromPool'; + +// 'UpdateTokenMetadata' and 'CreateTokenMetadata' have dynamic layouts + +const MOVE_STAKE_LAYOUT = BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + BufferLayout.ns64('transientStakeSeed'), +]); + +const UPDATE_VALIDATOR_LIST_BALANCE_LAYOUT = BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.u32('startIndex'), + BufferLayout.u8('noMerge'), +]); + +export function tokenMetadataLayout( + instruction: number, + nameLength: number, + symbolLength: number, + uriLength: number, +) { + if (nameLength > METADATA_MAX_NAME_LENGTH) { + throw 'maximum token name length is 32 characters'; + } + + if (symbolLength > METADATA_MAX_SYMBOL_LENGTH) { + throw 'maximum token symbol length is 10 characters'; + } + + if (uriLength > METADATA_MAX_URI_LENGTH) { + throw 'maximum token uri length is 200 characters'; + } + + return { + index: instruction, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.u32('nameLen'), + BufferLayout.blob(nameLength, 'name'), + BufferLayout.u32('symbolLen'), + BufferLayout.blob(symbolLength, 'symbol'), + BufferLayout.u32('uriLen'), + BufferLayout.blob(uriLength, 'uri'), + ]), + }; +} + +/** + * An enumeration of valid stake InstructionType's + * @internal + */ +export const STAKE_POOL_INSTRUCTION_LAYOUTS: { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + [type in StakePoolInstructionType]: InstructionType; +} = Object.freeze({ + AddValidatorToPool: { + index: 1, + layout: BufferLayout.struct([BufferLayout.u8('instruction'), BufferLayout.u32('seed')]), + }, + RemoveValidatorFromPool: { + index: 2, + layout: BufferLayout.struct([BufferLayout.u8('instruction')]), + }, + DecreaseValidatorStake: { + index: 3, + layout: MOVE_STAKE_LAYOUT, + }, + IncreaseValidatorStake: { + index: 4, + layout: MOVE_STAKE_LAYOUT, + }, + UpdateValidatorListBalance: { + index: 6, + layout: UPDATE_VALIDATOR_LIST_BALANCE_LAYOUT, + }, + UpdateStakePoolBalance: { + index: 7, + layout: BufferLayout.struct([BufferLayout.u8('instruction')]), + }, + CleanupRemovedValidatorEntries: { + index: 8, + layout: BufferLayout.struct([BufferLayout.u8('instruction')]), + }, + DepositStake: { + index: 9, + layout: BufferLayout.struct([BufferLayout.u8('instruction')]), + }, + /// Withdraw the token from the pool at the current ratio. + WithdrawStake: { + index: 10, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('poolTokens'), + ]), + }, + /// Deposit SOL directly into the pool's reserve account. The output is a "pool" token + /// representing ownership into the pool. Inputs are converted to the current ratio. + DepositSol: { + index: 14, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + ]), + }, + /// Withdraw SOL directly from the pool's reserve account. Fails if the + /// reserve does not have enough SOL. + WithdrawSol: { + index: 16, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('poolTokens'), + ]), + }, + IncreaseAdditionalValidatorStake: { + index: 19, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + BufferLayout.ns64('transientStakeSeed'), + BufferLayout.ns64('ephemeralStakeSeed'), + ]), + }, + DecreaseAdditionalValidatorStake: { + index: 20, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + BufferLayout.ns64('transientStakeSeed'), + BufferLayout.ns64('ephemeralStakeSeed'), + ]), + }, + DecreaseValidatorStakeWithReserve: { + index: 21, + layout: MOVE_STAKE_LAYOUT, + }, + Redelegate: { + index: 22, + layout: BufferLayout.struct([BufferLayout.u8('instruction')]), + }, +}); + +/** + * Cleans up validator stake account entries marked as `ReadyForRemoval` + */ +export type CleanupRemovedValidatorEntriesParams = { + stakePool: PublicKey; + validatorList: PublicKey; +}; + +/** + * Updates balances of validator and transient stake accounts in the pool. + */ +export type UpdateValidatorListBalanceParams = { + stakePool: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + reserveStake: PublicKey; + validatorAndTransientStakePairs: PublicKey[]; + startIndex: number; + noMerge: boolean; +}; + +/** + * Updates total pool balance based on balances in the reserve and validator list. + */ +export type UpdateStakePoolBalanceParams = { + stakePool: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + reserveStake: PublicKey; + managerFeeAccount: PublicKey; + poolMint: PublicKey; +}; + +/** + * (Staker only) Decrease active stake on a validator, eventually moving it to the reserve + */ +export type DecreaseValidatorStakeParams = { + stakePool: PublicKey; + staker: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + validatorStake: PublicKey; + transientStake: PublicKey; + // Amount of lamports to split into the transient stake account + lamports: number; + // Seed to used to create the transient stake account + transientStakeSeed: number; +}; + +export interface DecreaseValidatorStakeWithReserveParams extends DecreaseValidatorStakeParams { + reserveStake: PublicKey; +} + +export interface DecreaseAdditionalValidatorStakeParams extends DecreaseValidatorStakeParams { + reserveStake: PublicKey; + ephemeralStake: PublicKey; + ephemeralStakeSeed: number; +} + +/** + * (Staker only) Increase stake on a validator from the reserve account. + */ +export type IncreaseValidatorStakeParams = { + stakePool: PublicKey; + staker: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + reserveStake: PublicKey; + transientStake: PublicKey; + validatorStake: PublicKey; + validatorVote: PublicKey; + // Amount of lamports to split into the transient stake account + lamports: number; + // Seed to used to create the transient stake account + transientStakeSeed: number; +}; + +export interface IncreaseAdditionalValidatorStakeParams extends IncreaseValidatorStakeParams { + ephemeralStake: PublicKey; + ephemeralStakeSeed: number; +} + +/** + * Deposits a stake account into the pool in exchange for pool tokens + */ +export type DepositStakeParams = { + stakePool: PublicKey; + validatorList: PublicKey; + depositAuthority: PublicKey; + withdrawAuthority: PublicKey; + depositStake: PublicKey; + validatorStake: PublicKey; + reserveStake: PublicKey; + destinationPoolAccount: PublicKey; + managerFeeAccount: PublicKey; + referralPoolAccount: PublicKey; + poolMint: PublicKey; +}; + +/** + * Withdraws a stake account from the pool in exchange for pool tokens + */ +export type WithdrawStakeParams = { + stakePool: PublicKey; + validatorList: PublicKey; + withdrawAuthority: PublicKey; + validatorStake: PublicKey; + destinationStake: PublicKey; + destinationStakeAuthority: PublicKey; + sourceTransferAuthority: PublicKey; + sourcePoolAccount: PublicKey; + managerFeeAccount: PublicKey; + poolMint: PublicKey; + poolTokens: number; +}; + +/** + * Withdraw sol instruction params + */ +export type WithdrawSolParams = { + stakePool: PublicKey; + sourcePoolAccount: PublicKey; + withdrawAuthority: PublicKey; + reserveStake: PublicKey; + destinationSystemAccount: PublicKey; + sourceTransferAuthority: PublicKey; + solWithdrawAuthority?: PublicKey | undefined; + managerFeeAccount: PublicKey; + poolMint: PublicKey; + poolTokens: number; +}; + +/** + * Deposit SOL directly into the pool's reserve account. The output is a "pool" token + * representing ownership into the pool. Inputs are converted to the current ratio. + */ +export type DepositSolParams = { + stakePool: PublicKey; + depositAuthority?: PublicKey | undefined; + withdrawAuthority: PublicKey; + reserveStake: PublicKey; + fundingAccount: PublicKey; + destinationPoolAccount: PublicKey; + managerFeeAccount: PublicKey; + referralPoolAccount: PublicKey; + poolMint: PublicKey; + lamports: number; +}; + +export type CreateTokenMetadataParams = { + stakePool: PublicKey; + manager: PublicKey; + tokenMetadata: PublicKey; + withdrawAuthority: PublicKey; + poolMint: PublicKey; + payer: PublicKey; + name: string; + symbol: string; + uri: string; +}; + +export type UpdateTokenMetadataParams = { + stakePool: PublicKey; + manager: PublicKey; + tokenMetadata: PublicKey; + withdrawAuthority: PublicKey; + name: string; + symbol: string; + uri: string; +}; + +export type AddValidatorToPoolParams = { + stakePool: PublicKey; + staker: PublicKey; + reserveStake: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + validatorStake: PublicKey; + validatorVote: PublicKey; + seed?: number; +}; + +export type RemoveValidatorFromPoolParams = { + stakePool: PublicKey; + staker: PublicKey; + withdrawAuthority: PublicKey; + validatorList: PublicKey; + validatorStake: PublicKey; + transientStake: PublicKey; +}; + +/** + * Stake Pool Instruction class + */ +export class StakePoolInstruction { + /** + * Creates instruction to add a validator into the stake pool. + */ + static addValidatorToPool(params: AddValidatorToPoolParams): TransactionInstruction { + const { + stakePool, + staker, + reserveStake, + withdrawAuthority, + validatorList, + validatorStake, + validatorVote, + seed, + } = params; + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool; + const data = encodeData(type, { seed: seed == undefined ? 0 : seed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: validatorVote, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: STAKE_CONFIG_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates instruction to remove a validator from the stake pool. + */ + static removeValidatorFromPool(params: RemoveValidatorFromPoolParams): TransactionInstruction { + const { stakePool, staker, withdrawAuthority, validatorList, validatorStake, transientStake } = + params; + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.RemoveValidatorFromPool; + const data = encodeData(type); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates instruction to update a set of validators in the stake pool. + */ + static updateValidatorListBalance( + params: UpdateValidatorListBalanceParams, + ): TransactionInstruction { + const { + stakePool, + withdrawAuthority, + validatorList, + reserveStake, + startIndex, + noMerge, + validatorAndTransientStakePairs, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.UpdateValidatorListBalance; + const data = encodeData(type, { startIndex, noMerge: noMerge ? 1 : 0 }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ...validatorAndTransientStakePairs.map((pubkey) => ({ + pubkey, + isSigner: false, + isWritable: true, + })), + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates instruction to update the overall stake pool balance. + */ + static updateStakePoolBalance(params: UpdateStakePoolBalanceParams): TransactionInstruction { + const { + stakePool, + withdrawAuthority, + validatorList, + reserveStake, + managerFeeAccount, + poolMint, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.UpdateStakePoolBalance; + const data = encodeData(type); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: false }, + { pubkey: managerFeeAccount, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates instruction to cleanup removed validator entries. + */ + static cleanupRemovedValidatorEntries( + params: CleanupRemovedValidatorEntriesParams, + ): TransactionInstruction { + const { stakePool, validatorList } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.CleanupRemovedValidatorEntries; + const data = encodeData(type); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `IncreaseValidatorStake` instruction (rebalance from reserve account to + * transient account) + */ + static increaseValidatorStake(params: IncreaseValidatorStakeParams): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + reserveStake, + transientStake, + validatorStake, + validatorVote, + lamports, + transientStakeSeed, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.IncreaseValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: false }, + { pubkey: validatorVote, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: STAKE_CONFIG_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `IncreaseAdditionalValidatorStake` instruction (rebalance from reserve account to + * transient account) + */ + static increaseAdditionalValidatorStake( + params: IncreaseAdditionalValidatorStakeParams, + ): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + reserveStake, + transientStake, + validatorStake, + validatorVote, + lamports, + transientStakeSeed, + ephemeralStake, + ephemeralStakeSeed, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.IncreaseAdditionalValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed, ephemeralStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: ephemeralStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: false }, + { pubkey: validatorVote, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: STAKE_CONFIG_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `DecreaseValidatorStake` instruction (rebalance from validator account to + * transient account) + */ + static decreaseValidatorStake(params: DecreaseValidatorStakeParams): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + validatorStake, + transientStake, + lamports, + transientStakeSeed, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DecreaseValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `DecreaseValidatorStakeWithReserve` instruction (rebalance from + * validator account to transient account) + */ + static decreaseValidatorStakeWithReserve( + params: DecreaseValidatorStakeWithReserveParams, + ): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + reserveStake, + validatorStake, + transientStake, + lamports, + transientStakeSeed, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DecreaseValidatorStakeWithReserve; + const data = encodeData(type, { lamports, transientStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `DecreaseAdditionalValidatorStake` instruction (rebalance from + * validator account to transient account) + */ + static decreaseAdditionalValidatorStake( + params: DecreaseAdditionalValidatorStakeParams, + ): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + reserveStake, + validatorStake, + transientStake, + lamports, + transientStakeSeed, + ephemeralStakeSeed, + ephemeralStake, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DecreaseAdditionalValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed, ephemeralStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: ephemeralStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates a transaction instruction to deposit a stake account into a stake pool. + */ + static depositStake(params: DepositStakeParams): TransactionInstruction { + const { + stakePool, + validatorList, + depositAuthority, + withdrawAuthority, + depositStake, + validatorStake, + reserveStake, + destinationPoolAccount, + managerFeeAccount, + referralPoolAccount, + poolMint, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DepositStake; + const data = encodeData(type); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: depositAuthority, isSigner: false, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: depositStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: destinationPoolAccount, isSigner: false, isWritable: true }, + { pubkey: managerFeeAccount, isSigner: false, isWritable: true }, + { pubkey: referralPoolAccount, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates a transaction instruction to deposit SOL into a stake pool. + */ + static depositSol(params: DepositSolParams): TransactionInstruction { + const { + stakePool, + withdrawAuthority, + depositAuthority, + reserveStake, + fundingAccount, + destinationPoolAccount, + managerFeeAccount, + referralPoolAccount, + poolMint, + lamports, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol; + const data = encodeData(type, { lamports }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: fundingAccount, isSigner: true, isWritable: true }, + { pubkey: destinationPoolAccount, isSigner: false, isWritable: true }, + { pubkey: managerFeeAccount, isSigner: false, isWritable: true }, + { pubkey: referralPoolAccount, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + if (depositAuthority) { + keys.push({ + pubkey: depositAuthority, + isSigner: true, + isWritable: false, + }); + } + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates a transaction instruction to withdraw active stake from a stake pool. + */ + static withdrawStake(params: WithdrawStakeParams): TransactionInstruction { + const { + stakePool, + validatorList, + withdrawAuthority, + validatorStake, + destinationStake, + destinationStakeAuthority, + sourceTransferAuthority, + sourcePoolAccount, + managerFeeAccount, + poolMint, + poolTokens, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.WithdrawStake; + const data = encodeData(type, { poolTokens }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: destinationStake, isSigner: false, isWritable: true }, + { pubkey: destinationStakeAuthority, isSigner: false, isWritable: false }, + { pubkey: sourceTransferAuthority, isSigner: true, isWritable: false }, + { pubkey: sourcePoolAccount, isSigner: false, isWritable: true }, + { pubkey: managerFeeAccount, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates a transaction instruction to withdraw SOL from a stake pool. + */ + static withdrawSol(params: WithdrawSolParams): TransactionInstruction { + const { + stakePool, + withdrawAuthority, + sourceTransferAuthority, + sourcePoolAccount, + reserveStake, + destinationSystemAccount, + managerFeeAccount, + solWithdrawAuthority, + poolMint, + poolTokens, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.WithdrawSol; + const data = encodeData(type, { poolTokens }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: true }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: sourceTransferAuthority, isSigner: true, isWritable: false }, + { pubkey: sourcePoolAccount, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: destinationSystemAccount, isSigner: false, isWritable: true }, + { pubkey: managerFeeAccount, isSigner: false, isWritable: true }, + { pubkey: poolMint, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + if (solWithdrawAuthority) { + keys.push({ + pubkey: solWithdrawAuthority, + isSigner: true, + isWritable: false, + }); + } + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates an instruction to create metadata + * using the mpl token metadata program for the pool token + */ + static createTokenMetadata(params: CreateTokenMetadataParams): TransactionInstruction { + const { + stakePool, + withdrawAuthority, + tokenMetadata, + manager, + payer, + poolMint, + name, + symbol, + uri, + } = params; + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: manager, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: poolMint, isSigner: false, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: tokenMetadata, isSigner: false, isWritable: true }, + { pubkey: METADATA_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, + ]; + + const type = tokenMetadataLayout(17, name.length, symbol.length, uri.length); + const data = encodeData(type, { + nameLen: name.length, + name: Buffer.from(name), + symbolLen: symbol.length, + symbol: Buffer.from(symbol), + uriLen: uri.length, + uri: Buffer.from(uri), + }); + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates an instruction to update metadata + * in the mpl token metadata program account for the pool token + */ + static updateTokenMetadata(params: UpdateTokenMetadataParams): TransactionInstruction { + const { stakePool, withdrawAuthority, tokenMetadata, manager, name, symbol, uri } = params; + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: manager, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: tokenMetadata, isSigner: false, isWritable: true }, + { pubkey: METADATA_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + const type = tokenMetadataLayout(18, name.length, symbol.length, uri.length); + const data = encodeData(type, { + nameLen: name.length, + name: Buffer.from(name), + symbolLen: symbol.length, + symbol: Buffer.from(symbol), + uriLen: uri.length, + uri: Buffer.from(uri), + }); + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Decode a deposit stake pool instruction and retrieve the instruction params. + */ + static decodeDepositStake(instruction: TransactionInstruction): DepositStakeParams { + this.checkProgramId(instruction.programId); + this.checkKeyLength(instruction.keys, 11); + + decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositStake, instruction.data); + + return { + stakePool: instruction.keys[0].pubkey, + validatorList: instruction.keys[1].pubkey, + depositAuthority: instruction.keys[2].pubkey, + withdrawAuthority: instruction.keys[3].pubkey, + depositStake: instruction.keys[4].pubkey, + validatorStake: instruction.keys[5].pubkey, + reserveStake: instruction.keys[6].pubkey, + destinationPoolAccount: instruction.keys[7].pubkey, + managerFeeAccount: instruction.keys[8].pubkey, + referralPoolAccount: instruction.keys[9].pubkey, + poolMint: instruction.keys[10].pubkey, + }; + } + + /** + * Decode a deposit sol instruction and retrieve the instruction params. + */ + static decodeDepositSol(instruction: TransactionInstruction): DepositSolParams { + this.checkProgramId(instruction.programId); + this.checkKeyLength(instruction.keys, 9); + + const { amount } = decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol, instruction.data); + + return { + stakePool: instruction.keys[0].pubkey, + depositAuthority: instruction.keys[1].pubkey, + withdrawAuthority: instruction.keys[2].pubkey, + reserveStake: instruction.keys[3].pubkey, + fundingAccount: instruction.keys[4].pubkey, + destinationPoolAccount: instruction.keys[5].pubkey, + managerFeeAccount: instruction.keys[6].pubkey, + referralPoolAccount: instruction.keys[7].pubkey, + poolMint: instruction.keys[8].pubkey, + lamports: amount, + }; + } + + /** + * @internal + */ + private static checkProgramId(programId: PublicKey) { + if (!programId.equals(StakeProgram.programId)) { + throw new Error('Invalid instruction; programId is not StakeProgram'); + } + } + + /** + * @internal + */ + private static checkKeyLength(keys: Array, expectedLength: number) { + if (keys.length < expectedLength) { + throw new Error( + `Invalid instruction; found ${keys.length} keys, expected at least ${expectedLength}`, + ); + } + } +} diff --git a/clients/js-legacy/src/layouts.ts b/clients/js-legacy/src/layouts.ts new file mode 100644 index 00000000..bc6c3cd3 --- /dev/null +++ b/clients/js-legacy/src/layouts.ts @@ -0,0 +1,246 @@ +import { Layout, publicKey, u64, option, vec } from './codecs'; +import { struct, Layout as LayoutCls, u8, u32 } from 'buffer-layout'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { + Infer, + number, + nullable, + enums, + type, + coerce, + instance, + string, + optional, +} from 'superstruct'; + +export interface Fee { + denominator: BN; + numerator: BN; +} + +const feeFields = [u64('denominator'), u64('numerator')]; + +export enum AccountType { + Uninitialized, + StakePool, + ValidatorList, +} + +export const BigNumFromString = coerce(instance(BN), string(), (value) => { + if (typeof value === 'string') return new BN(value, 10); + throw new Error('invalid big num'); +}); + +export const PublicKeyFromString = coerce( + instance(PublicKey), + string(), + (value) => new PublicKey(value), +); + +export class FutureEpochLayout extends LayoutCls { + layout: Layout; + discriminator: Layout; + + constructor(layout: Layout, property?: string) { + super(-1, property); + this.layout = layout; + this.discriminator = u8(); + } + + encode(src: T | null, b: Buffer, offset = 0): number { + if (src === null || src === undefined) { + return this.discriminator.encode(0, b, offset); + } + // This isn't right, but we don't typically encode outside of tests + this.discriminator.encode(2, b, offset); + return this.layout.encode(src, b, offset + 1) + 1; + } + + decode(b: Buffer, offset = 0): T | null { + const discriminator = this.discriminator.decode(b, offset); + if (discriminator === 0) { + return null; + } else if (discriminator === 1 || discriminator === 2) { + return this.layout.decode(b, offset + 1); + } + throw new Error('Invalid future epoch ' + this.property); + } + + getSpan(b: Buffer, offset = 0): number { + const discriminator = this.discriminator.decode(b, offset); + if (discriminator === 0) { + return 1; + } else if (discriminator === 1 || discriminator === 2) { + return this.layout.getSpan(b, offset + 1) + 1; + } + throw new Error('Invalid future epoch ' + this.property); + } +} + +export function futureEpoch(layout: Layout, property?: string): LayoutCls { + return new FutureEpochLayout(layout, property); +} + +export type StakeAccountType = Infer; +export const StakeAccountType = enums(['uninitialized', 'initialized', 'delegated', 'rewardsPool']); + +export type StakeMeta = Infer; +export const StakeMeta = type({ + rentExemptReserve: BigNumFromString, + authorized: type({ + staker: PublicKeyFromString, + withdrawer: PublicKeyFromString, + }), + lockup: type({ + unixTimestamp: number(), + epoch: number(), + custodian: PublicKeyFromString, + }), +}); + +export type StakeAccountInfo = Infer; +export const StakeAccountInfo = type({ + meta: StakeMeta, + stake: nullable( + type({ + delegation: type({ + voter: PublicKeyFromString, + stake: BigNumFromString, + activationEpoch: BigNumFromString, + deactivationEpoch: BigNumFromString, + warmupCooldownRate: number(), + }), + creditsObserved: number(), + }), + ), +}); + +export type StakeAccount = Infer; +export const StakeAccount = type({ + type: StakeAccountType, + info: optional(StakeAccountInfo), +}); +export interface Lockup { + unixTimestamp: BN; + epoch: BN; + custodian: PublicKey; +} + +export interface StakePool { + accountType: AccountType; + manager: PublicKey; + staker: PublicKey; + stakeDepositAuthority: PublicKey; + stakeWithdrawBumpSeed: number; + validatorList: PublicKey; + reserveStake: PublicKey; + poolMint: PublicKey; + managerFeeAccount: PublicKey; + tokenProgramId: PublicKey; + totalLamports: BN; + poolTokenSupply: BN; + lastUpdateEpoch: BN; + lockup: Lockup; + epochFee: Fee; + nextEpochFee?: Fee | undefined; + preferredDepositValidatorVoteAddress?: PublicKey | undefined; + preferredWithdrawValidatorVoteAddress?: PublicKey | undefined; + stakeDepositFee: Fee; + stakeWithdrawalFee: Fee; + nextStakeWithdrawalFee?: Fee | undefined; + stakeReferralFee: number; + solDepositAuthority?: PublicKey | undefined; + solDepositFee: Fee; + solReferralFee: number; + solWithdrawAuthority?: PublicKey | undefined; + solWithdrawalFee: Fee; + nextSolWithdrawalFee?: Fee | undefined; + lastEpochPoolTokenSupply: BN; + lastEpochTotalLamports: BN; +} + +export const StakePoolLayout = struct([ + u8('accountType'), + publicKey('manager'), + publicKey('staker'), + publicKey('stakeDepositAuthority'), + u8('stakeWithdrawBumpSeed'), + publicKey('validatorList'), + publicKey('reserveStake'), + publicKey('poolMint'), + publicKey('managerFeeAccount'), + publicKey('tokenProgramId'), + u64('totalLamports'), + u64('poolTokenSupply'), + u64('lastUpdateEpoch'), + struct([u64('unixTimestamp'), u64('epoch'), publicKey('custodian')], 'lockup'), + struct(feeFields, 'epochFee'), + futureEpoch(struct(feeFields), 'nextEpochFee'), + option(publicKey(), 'preferredDepositValidatorVoteAddress'), + option(publicKey(), 'preferredWithdrawValidatorVoteAddress'), + struct(feeFields, 'stakeDepositFee'), + struct(feeFields, 'stakeWithdrawalFee'), + futureEpoch(struct(feeFields), 'nextStakeWithdrawalFee'), + u8('stakeReferralFee'), + option(publicKey(), 'solDepositAuthority'), + struct(feeFields, 'solDepositFee'), + u8('solReferralFee'), + option(publicKey(), 'solWithdrawAuthority'), + struct(feeFields, 'solWithdrawalFee'), + futureEpoch(struct(feeFields), 'nextSolWithdrawalFee'), + u64('lastEpochPoolTokenSupply'), + u64('lastEpochTotalLamports'), +]); + +export enum ValidatorStakeInfoStatus { + Active, + DeactivatingTransient, + ReadyForRemoval, +} + +export interface ValidatorStakeInfo { + status: ValidatorStakeInfoStatus; + voteAccountAddress: PublicKey; + activeStakeLamports: BN; + transientStakeLamports: BN; + transientSeedSuffixStart: BN; + transientSeedSuffixEnd: BN; + lastUpdateEpoch: BN; +} + +export const ValidatorStakeInfoLayout = struct([ + /// Amount of active stake delegated to this validator + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + u64('activeStakeLamports'), + /// Amount of transient stake delegated to this validator + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + u64('transientStakeLamports'), + /// Last epoch the active and transient stake lamports fields were updated + u64('lastUpdateEpoch'), + /// Start of the validator transient account seed suffixes + u64('transientSeedSuffixStart'), + /// End of the validator transient account seed suffixes + u64('transientSeedSuffixEnd'), + /// Status of the validator stake account + u8('status'), + /// Validator vote account address + publicKey('voteAccountAddress'), +]); + +export interface ValidatorList { + /// Account type, must be ValidatorList currently + accountType: number; + /// Maximum allowable number of validators + maxValidators: number; + /// List of stake info for each validator in the pool + validators: ValidatorStakeInfo[]; +} + +export const ValidatorListLayout = struct([ + u8('accountType'), + u32('maxValidators'), + vec(ValidatorStakeInfoLayout, 'validators'), +]); diff --git a/clients/js-legacy/src/types/buffer-layout.d.ts b/clients/js-legacy/src/types/buffer-layout.d.ts new file mode 100644 index 00000000..5ef8ba4c --- /dev/null +++ b/clients/js-legacy/src/types/buffer-layout.d.ts @@ -0,0 +1,29 @@ +declare module 'buffer-layout' { + export class Layout { + span: number; + property?: string; + constructor(span: number, property?: string); + decode(b: Buffer | undefined, offset?: number): T; + encode(src: T, b: Buffer, offset?: number): number; + getSpan(b: Buffer, offset?: number): number; + replicate(name: string): this; + } + export function struct( + fields: Layout[], + property?: string, + decodePrefixes?: boolean, + ): Layout; + export function seq( + elementLayout: Layout, + count: number | Layout, + property?: string, + ): Layout; + export function offset(layout: Layout, offset?: number, property?: string): Layout; + export function blob(length: number | Layout, property?: string): Layout; + export function s32(property?: string): Layout; + export function u32(property?: string): Layout; + export function s16(property?: string): Layout; + export function u16(property?: string): Layout; + export function s8(property?: string): Layout; + export function u8(property?: string): Layout; +} diff --git a/clients/js-legacy/src/utils/index.ts b/clients/js-legacy/src/utils/index.ts new file mode 100644 index 00000000..e93c2cde --- /dev/null +++ b/clients/js-legacy/src/utils/index.ts @@ -0,0 +1,12 @@ +export * from './math'; +export * from './program-address'; +export * from './stake'; +export * from './instruction'; + +export function arrayChunk(array: any[], size: number): any[] { + const result = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +} diff --git a/clients/js-legacy/src/utils/instruction.ts b/clients/js-legacy/src/utils/instruction.ts new file mode 100644 index 00000000..24ccad8a --- /dev/null +++ b/clients/js-legacy/src/utils/instruction.ts @@ -0,0 +1,46 @@ +import * as BufferLayout from '@solana/buffer-layout'; +import { Buffer } from 'buffer'; + +/** + * @internal + */ +export type InstructionType = { + /** The Instruction index (from solana upstream program) */ + index: number; + /** The BufferLayout to use to build data */ + layout: BufferLayout.Layout; +}; + +/** + * Populate a buffer of instruction data using an InstructionType + * @internal + */ +export function encodeData(type: InstructionType, fields?: any): Buffer { + const allocLength = type.layout.span; + const data = Buffer.alloc(allocLength); + const layoutFields = Object.assign({ instruction: type.index }, fields); + type.layout.encode(layoutFields, data); + + return data; +} + +/** + * Decode instruction data buffer using an InstructionType + * @internal + */ +export function decodeData(type: InstructionType, buffer: Buffer): any { + let data; + try { + data = type.layout.decode(buffer); + } catch (err) { + throw new Error('invalid instruction; ' + err); + } + + if (data.instruction !== type.index) { + throw new Error( + `invalid instruction; instruction index mismatch ${data.instruction} != ${type.index}`, + ); + } + + return data; +} diff --git a/clients/js-legacy/src/utils/math.ts b/clients/js-legacy/src/utils/math.ts new file mode 100644 index 00000000..5f939bc8 --- /dev/null +++ b/clients/js-legacy/src/utils/math.ts @@ -0,0 +1,27 @@ +import BN from 'bn.js'; +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; + +export function solToLamports(amount: number): number { + if (isNaN(amount)) return Number(0); + return Number(amount * LAMPORTS_PER_SOL); +} + +export function lamportsToSol(lamports: number | BN | bigint): number { + if (typeof lamports === 'number') { + return Math.abs(lamports) / LAMPORTS_PER_SOL; + } + if (typeof lamports === 'bigint') { + return Math.abs(Number(lamports)) / LAMPORTS_PER_SOL; + } + + let signMultiplier = 1; + if (lamports.isNeg()) { + signMultiplier = -1; + } + + const absLamports = lamports.abs(); + const lamportsString = absLamports.toString(10).padStart(10, '0'); + const splitIndex = lamportsString.length - 9; + const solString = lamportsString.slice(0, splitIndex) + '.' + lamportsString.slice(splitIndex); + return signMultiplier * parseFloat(solString); +} diff --git a/clients/js-legacy/src/utils/program-address.ts b/clients/js-legacy/src/utils/program-address.ts new file mode 100644 index 00000000..827f4021 --- /dev/null +++ b/clients/js-legacy/src/utils/program-address.ts @@ -0,0 +1,89 @@ +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { Buffer } from 'buffer'; +import { + METADATA_PROGRAM_ID, + EPHEMERAL_STAKE_SEED_PREFIX, + TRANSIENT_STAKE_SEED_PREFIX, +} from '../constants'; + +/** + * Generates the withdraw authority program address for the stake pool + */ +export async function findWithdrawAuthorityProgramAddress( + programId: PublicKey, + stakePoolAddress: PublicKey, +) { + const [publicKey] = await PublicKey.findProgramAddress( + [stakePoolAddress.toBuffer(), Buffer.from('withdraw')], + programId, + ); + return publicKey; +} + +/** + * Generates the stake program address for a validator's vote account + */ +export async function findStakeProgramAddress( + programId: PublicKey, + voteAccountAddress: PublicKey, + stakePoolAddress: PublicKey, + seed?: number, +) { + const [publicKey] = await PublicKey.findProgramAddress( + [ + voteAccountAddress.toBuffer(), + stakePoolAddress.toBuffer(), + seed ? new BN(seed).toArrayLike(Buffer, 'le', 4) : Buffer.alloc(0), + ], + programId, + ); + return publicKey; +} + +/** + * Generates the stake program address for a validator's vote account + */ +export async function findTransientStakeProgramAddress( + programId: PublicKey, + voteAccountAddress: PublicKey, + stakePoolAddress: PublicKey, + seed: BN, +) { + const [publicKey] = await PublicKey.findProgramAddress( + [ + TRANSIENT_STAKE_SEED_PREFIX, + voteAccountAddress.toBuffer(), + stakePoolAddress.toBuffer(), + seed.toArrayLike(Buffer, 'le', 8), + ], + programId, + ); + return publicKey; +} + +/** + * Generates the ephemeral program address for stake pool redelegation + */ +export async function findEphemeralStakeProgramAddress( + programId: PublicKey, + stakePoolAddress: PublicKey, + seed: BN, +) { + const [publicKey] = await PublicKey.findProgramAddress( + [EPHEMERAL_STAKE_SEED_PREFIX, stakePoolAddress.toBuffer(), seed.toArrayLike(Buffer, 'le', 8)], + programId, + ); + return publicKey; +} + +/** + * Generates the metadata program address for the stake pool + */ +export function findMetadataAddress(stakePoolMintAddress: PublicKey) { + const [publicKey] = PublicKey.findProgramAddressSync( + [Buffer.from('metadata'), METADATA_PROGRAM_ID.toBuffer(), stakePoolMintAddress.toBuffer()], + METADATA_PROGRAM_ID, + ); + return publicKey; +} diff --git a/clients/js-legacy/src/utils/stake.ts b/clients/js-legacy/src/utils/stake.ts new file mode 100644 index 00000000..ed4fc1e8 --- /dev/null +++ b/clients/js-legacy/src/utils/stake.ts @@ -0,0 +1,229 @@ +import { + Connection, + Keypair, + PublicKey, + StakeProgram, + SystemProgram, + TransactionInstruction, +} from '@solana/web3.js'; +import { findStakeProgramAddress, findTransientStakeProgramAddress } from './program-address'; +import BN from 'bn.js'; + +import { lamportsToSol } from './math'; +import { WithdrawAccount } from '../index'; +import { + Fee, + StakePool, + ValidatorList, + ValidatorListLayout, + ValidatorStakeInfoStatus, +} from '../layouts'; +import { MINIMUM_ACTIVE_STAKE, STAKE_POOL_PROGRAM_ID } from '../constants'; + +export async function getValidatorListAccount(connection: Connection, pubkey: PublicKey) { + const account = await connection.getAccountInfo(pubkey); + if (!account) { + throw new Error('Invalid validator list account'); + } + + return { + pubkey, + account: { + data: ValidatorListLayout.decode(account?.data) as ValidatorList, + executable: account.executable, + lamports: account.lamports, + owner: account.owner, + }, + }; +} + +export interface ValidatorAccount { + type: 'preferred' | 'active' | 'transient' | 'reserve'; + voteAddress?: PublicKey | undefined; + stakeAddress: PublicKey; + lamports: BN; +} + +export async function prepareWithdrawAccounts( + connection: Connection, + stakePool: StakePool, + stakePoolAddress: PublicKey, + amount: BN, + compareFn?: (a: ValidatorAccount, b: ValidatorAccount) => number, + skipFee?: boolean, +): Promise { + const validatorListAcc = await connection.getAccountInfo(stakePool.validatorList); + const validatorList = ValidatorListLayout.decode(validatorListAcc?.data) as ValidatorList; + + if (!validatorList?.validators || validatorList?.validators.length == 0) { + throw new Error('No accounts found'); + } + + const minBalanceForRentExemption = await connection.getMinimumBalanceForRentExemption( + StakeProgram.space, + ); + const minBalance = new BN(minBalanceForRentExemption + MINIMUM_ACTIVE_STAKE); + + let accounts = [] as Array<{ + type: 'preferred' | 'active' | 'transient' | 'reserve'; + voteAddress?: PublicKey | undefined; + stakeAddress: PublicKey; + lamports: BN; + }>; + + // Prepare accounts + for (const validator of validatorList.validators) { + if (validator.status !== ValidatorStakeInfoStatus.Active) { + continue; + } + + const stakeAccountAddress = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + ); + + if (!validator.activeStakeLamports.isZero()) { + const isPreferred = stakePool?.preferredWithdrawValidatorVoteAddress?.equals( + validator.voteAccountAddress, + ); + accounts.push({ + type: isPreferred ? 'preferred' : 'active', + voteAddress: validator.voteAccountAddress, + stakeAddress: stakeAccountAddress, + lamports: validator.activeStakeLamports, + }); + } + + const transientStakeLamports = validator.transientStakeLamports.sub(minBalance); + if (transientStakeLamports.gt(new BN(0))) { + const transientStakeAccountAddress = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validator.voteAccountAddress, + stakePoolAddress, + validator.transientSeedSuffixStart, + ); + accounts.push({ + type: 'transient', + voteAddress: validator.voteAccountAddress, + stakeAddress: transientStakeAccountAddress, + lamports: transientStakeLamports, + }); + } + } + + // Sort from highest to lowest balance + accounts = accounts.sort(compareFn ? compareFn : (a, b) => b.lamports.sub(a.lamports).toNumber()); + + const reserveStake = await connection.getAccountInfo(stakePool.reserveStake); + const reserveStakeBalance = new BN((reserveStake?.lamports ?? 0) - minBalanceForRentExemption); + if (reserveStakeBalance.gt(new BN(0))) { + accounts.push({ + type: 'reserve', + stakeAddress: stakePool.reserveStake, + lamports: reserveStakeBalance, + }); + } + + // Prepare the list of accounts to withdraw from + const withdrawFrom: WithdrawAccount[] = []; + let remainingAmount = new BN(amount); + + const fee = stakePool.stakeWithdrawalFee; + const inverseFee: Fee = { + numerator: fee.denominator.sub(fee.numerator), + denominator: fee.denominator, + }; + + for (const type of ['preferred', 'active', 'transient', 'reserve']) { + const filteredAccounts = accounts.filter((a) => a.type == type); + + for (const { stakeAddress, voteAddress, lamports } of filteredAccounts) { + if (lamports.lte(minBalance) && type == 'transient') { + continue; + } + + let availableForWithdrawal = calcPoolTokensForDeposit(stakePool, lamports); + + if (!skipFee && !inverseFee.numerator.isZero()) { + availableForWithdrawal = availableForWithdrawal + .mul(inverseFee.denominator) + .div(inverseFee.numerator); + } + + const poolAmount = BN.min(availableForWithdrawal, remainingAmount); + if (poolAmount.lte(new BN(0))) { + continue; + } + + // Those accounts will be withdrawn completely with `claim` instruction + withdrawFrom.push({ stakeAddress, voteAddress, poolAmount }); + remainingAmount = remainingAmount.sub(poolAmount); + + if (remainingAmount.isZero()) { + break; + } + } + + if (remainingAmount.isZero()) { + break; + } + } + + // Not enough stake to withdraw the specified amount + if (remainingAmount.gt(new BN(0))) { + throw new Error( + `No stake accounts found in this pool with enough balance to withdraw ${lamportsToSol( + amount, + )} pool tokens.`, + ); + } + + return withdrawFrom; +} + +/** + * Calculate the pool tokens that should be minted for a deposit of `stakeLamports` + */ +export function calcPoolTokensForDeposit(stakePool: StakePool, stakeLamports: BN): BN { + if (stakePool.poolTokenSupply.isZero() || stakePool.totalLamports.isZero()) { + return stakeLamports; + } + const numerator = stakeLamports.mul(stakePool.poolTokenSupply); + return numerator.div(stakePool.totalLamports); +} + +/** + * Calculate lamports amount on withdrawal + */ +export function calcLamportsWithdrawAmount(stakePool: StakePool, poolTokens: BN): BN { + const numerator = poolTokens.mul(stakePool.totalLamports); + const denominator = stakePool.poolTokenSupply; + if (numerator.lt(denominator)) { + return new BN(0); + } + return numerator.div(denominator); +} + +export function newStakeAccount( + feePayer: PublicKey, + instructions: TransactionInstruction[], + lamports: number, +): Keypair { + // Account for tokens not specified, creating one + const stakeReceiverKeypair = Keypair.generate(); + console.log(`Creating account to receive stake ${stakeReceiverKeypair.publicKey}`); + + instructions.push( + // Creating new account + SystemProgram.createAccount({ + fromPubkey: feePayer, + newAccountPubkey: stakeReceiverKeypair.publicKey, + lamports, + space: StakeProgram.space, + programId: StakeProgram.programId, + }), + ); + + return stakeReceiverKeypair; +} diff --git a/clients/js-legacy/test/calculation.test.ts b/clients/js-legacy/test/calculation.test.ts new file mode 100644 index 00000000..dd6083ec --- /dev/null +++ b/clients/js-legacy/test/calculation.test.ts @@ -0,0 +1,15 @@ +import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { stakePoolMock } from './mocks'; +import { calcPoolTokensForDeposit } from '../src/utils/stake'; +import BN from 'bn.js'; + +describe('calculations', () => { + it('should successfully calculate pool tokens for a pool with a lot of stake', () => { + const lamports = new BN(LAMPORTS_PER_SOL * 100); + const bigStakePoolMock = stakePoolMock; + bigStakePoolMock.totalLamports = new BN('11000000000000000'); // 11 million SOL + bigStakePoolMock.poolTokenSupply = new BN('10000000000000000'); // 10 million tokens + const availableForWithdrawal = calcPoolTokensForDeposit(bigStakePoolMock, lamports); + expect(availableForWithdrawal.toNumber()).toEqual(90909090909); + }); +}); diff --git a/clients/js-legacy/test/equal.ts b/clients/js-legacy/test/equal.ts new file mode 100644 index 00000000..b86e417b --- /dev/null +++ b/clients/js-legacy/test/equal.ts @@ -0,0 +1,37 @@ +import BN from 'bn.js'; + +/** + * Helper function to do deep equality check because BNs are not equal. + * TODO: write this function recursively. For now, sufficient. + */ +export function deepStrictEqualBN(a: any, b: any) { + for (const key in a) { + if (b[key] instanceof BN) { + expect(b[key].toString()).toEqual(a[key].toString()); + } else { + if (a[key] instanceof Object) { + for (const subkey in a[key]) { + if (a[key][subkey] instanceof Object) { + if (a[key][subkey] instanceof BN) { + expect(b[key][subkey].toString()).toEqual(a[key][subkey].toString()); + } else { + for (const subsubkey in a[key][subkey]) { + if (a[key][subkey][subsubkey] instanceof BN) { + expect(b[key][subkey][subsubkey].toString()).toEqual( + a[key][subkey][subsubkey].toString(), + ); + } else { + expect(b[key][subkey][subsubkey]).toStrictEqual(a[key][subkey][subsubkey]); + } + } + } + } else { + expect(b[key][subkey]).toStrictEqual(a[key][subkey]); + } + } + } else { + expect(b[key]).toStrictEqual(a[key]); + } + } + } +} diff --git a/clients/js-legacy/test/instructions.test.ts b/clients/js-legacy/test/instructions.test.ts new file mode 100644 index 00000000..c3e9b7c9 --- /dev/null +++ b/clients/js-legacy/test/instructions.test.ts @@ -0,0 +1,541 @@ +// Very important! We need to do this polyfill before any of the imports because +// some web3.js dependencies store `crypto` elsewhere. +import { randomBytes } from 'crypto'; +Object.defineProperty(globalThis, 'crypto', { + value: { + getRandomValues: (arr: any) => randomBytes(arr.length), + }, +}); + +import { + PublicKey, + Connection, + Keypair, + SystemProgram, + StakeProgram, + AccountInfo, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, TokenAccountNotFoundError } from '@solana/spl-token'; +import { StakePoolLayout, ValidatorListLayout } from '../src/layouts'; +import { + STAKE_POOL_INSTRUCTION_LAYOUTS, + DepositSolParams, + AddValidatorToPoolParams, + RemoveValidatorFromPoolParams, + StakePoolInstruction, + depositSol, + withdrawSol, + withdrawStake, + getStakeAccount, + createPoolTokenMetadata, + updatePoolTokenMetadata, + tokenMetadataLayout, + addValidatorToPool, + removeValidatorFromPool, +} from '../src'; +import { STAKE_POOL_PROGRAM_ID } from '../src/constants'; + +import { decodeData, findStakeProgramAddress } from '../src/utils'; + +import { + mockRpc, + mockTokenAccount, + mockValidatorList, + mockValidatorsStakeAccount, + stakePoolMock, + CONSTANTS, + stakeAccountData, + uninitializedStakeAccount, + validatorListMock, +} from './mocks'; + +describe('StakePoolProgram', () => { + const connection = new Connection('http://127.0.0.1:8899'); + + connection.getMinimumBalanceForRentExemption = jest.fn(async () => 10000); + + const stakePoolAddress = new PublicKey('SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy'); + + const data = Buffer.alloc(1024); + StakePoolLayout.encode(stakePoolMock, data); + + const stakePoolAccount = >{ + executable: true, + owner: stakePoolAddress, + lamports: 99999, + data, + }; + + it('StakePoolInstruction.addValidatorToPool', () => { + const payload: AddValidatorToPoolParams = { + stakePool: stakePoolAddress, + staker: Keypair.generate().publicKey, + reserveStake: Keypair.generate().publicKey, + withdrawAuthority: Keypair.generate().publicKey, + validatorList: Keypair.generate().publicKey, + validatorStake: Keypair.generate().publicKey, + validatorVote: PublicKey.default, + seed: 0, + }; + + const instruction = StakePoolInstruction.addValidatorToPool(payload); + expect(instruction.keys).toHaveLength(13); + expect(instruction.keys[0].pubkey).toEqual(payload.stakePool); + expect(instruction.keys[1].pubkey).toEqual(payload.staker); + expect(instruction.keys[2].pubkey).toEqual(payload.reserveStake); + expect(instruction.keys[3].pubkey).toEqual(payload.withdrawAuthority); + expect(instruction.keys[4].pubkey).toEqual(payload.validatorList); + expect(instruction.keys[5].pubkey).toEqual(payload.validatorStake); + expect(instruction.keys[6].pubkey).toEqual(payload.validatorVote); + expect(instruction.keys[11].pubkey).toEqual(SystemProgram.programId); + expect(instruction.keys[12].pubkey).toEqual(StakeProgram.programId); + + const decodedData = decodeData( + STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool, + instruction.data, + ); + expect(decodedData.instruction).toEqual( + STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool.index, + ); + expect(decodedData.seed).toEqual(payload.seed); + }); + + it('StakePoolInstruction.removeValidatorFromPool', () => { + const payload: RemoveValidatorFromPoolParams = { + stakePool: stakePoolAddress, + staker: Keypair.generate().publicKey, + withdrawAuthority: Keypair.generate().publicKey, + validatorList: Keypair.generate().publicKey, + validatorStake: Keypair.generate().publicKey, + transientStake: Keypair.generate().publicKey, + }; + + const instruction = StakePoolInstruction.removeValidatorFromPool(payload); + expect(instruction.keys).toHaveLength(8); + expect(instruction.keys[0].pubkey).toEqual(payload.stakePool); + expect(instruction.keys[1].pubkey).toEqual(payload.staker); + expect(instruction.keys[2].pubkey).toEqual(payload.withdrawAuthority); + expect(instruction.keys[3].pubkey).toEqual(payload.validatorList); + expect(instruction.keys[4].pubkey).toEqual(payload.validatorStake); + expect(instruction.keys[5].pubkey).toEqual(payload.transientStake); + expect(instruction.keys[7].pubkey).toEqual(StakeProgram.programId); + + const decodedData = decodeData( + STAKE_POOL_INSTRUCTION_LAYOUTS.RemoveValidatorFromPool, + instruction.data, + ); + expect(decodedData.instruction).toEqual( + STAKE_POOL_INSTRUCTION_LAYOUTS.RemoveValidatorFromPool.index, + ); + }); + + it('StakePoolInstruction.depositSol', () => { + const payload: DepositSolParams = { + stakePool: stakePoolAddress, + withdrawAuthority: Keypair.generate().publicKey, + reserveStake: Keypair.generate().publicKey, + fundingAccount: Keypair.generate().publicKey, + destinationPoolAccount: Keypair.generate().publicKey, + managerFeeAccount: Keypair.generate().publicKey, + referralPoolAccount: Keypair.generate().publicKey, + poolMint: Keypair.generate().publicKey, + lamports: 99999, + }; + + const instruction = StakePoolInstruction.depositSol(payload); + + expect(instruction.keys).toHaveLength(10); + expect(instruction.keys[0].pubkey).toEqual(payload.stakePool); + expect(instruction.keys[1].pubkey).toEqual(payload.withdrawAuthority); + expect(instruction.keys[3].pubkey).toEqual(payload.fundingAccount); + expect(instruction.keys[4].pubkey).toEqual(payload.destinationPoolAccount); + expect(instruction.keys[5].pubkey).toEqual(payload.managerFeeAccount); + expect(instruction.keys[6].pubkey).toEqual(payload.referralPoolAccount); + expect(instruction.keys[8].pubkey).toEqual(SystemProgram.programId); + expect(instruction.keys[9].pubkey).toEqual(TOKEN_PROGRAM_ID); + + const decodedData = decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol, instruction.data); + + expect(decodedData.instruction).toEqual(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol.index); + expect(decodedData.lamports).toEqual(payload.lamports); + + payload.depositAuthority = Keypair.generate().publicKey; + + const instruction2 = StakePoolInstruction.depositSol(payload); + + expect(instruction2.keys).toHaveLength(11); + expect(instruction2.keys[10].pubkey).toEqual(payload.depositAuthority); + }); + + describe('addValidatorToPool', () => { + const validatorList = mockValidatorList(); + const decodedValidatorList = ValidatorListLayout.decode(validatorList.data); + const voteAccount = decodedValidatorList.validators[0].voteAccountAddress; + + it('should throw an error when trying to add an existing validator', async () => { + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + return mockValidatorList(); + }); + await expect(addValidatorToPool(connection, stakePoolAddress, voteAccount)).rejects.toThrow( + Error('Vote account is already in validator list'), + ); + }); + + it('should successfully add a validator', async () => { + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + return >{ + executable: true, + owner: new PublicKey(0), + lamports: 0, + data, + }; + }); + const res = await addValidatorToPool( + connection, + stakePoolAddress, + validatorListMock.validators[0].voteAccountAddress, + ); + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2); + expect(res.instructions).toHaveLength(1); + // Make sure that the validator vote account being added is the one we passed + expect(res.instructions[0].keys[6].pubkey).toEqual( + validatorListMock.validators[0].voteAccountAddress, + ); + }); + }); + + describe('removeValidatorFromPool', () => { + const voteAccount = Keypair.generate().publicKey; + + it('should throw an error when trying to remove a non-existing validator', async () => { + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(stakePoolMock.validatorList)) { + return mockValidatorList(); + } + return >{ + executable: true, + owner: new PublicKey(0), + lamports: 0, + data, + }; + }); + await expect( + removeValidatorFromPool(connection, stakePoolAddress, voteAccount), + ).rejects.toThrow(Error('Vote account is not already in validator list')); + }); + + it('should successfully remove a validator', async () => { + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(stakePoolMock.validatorList)) { + return mockValidatorList(); + } + return >{ + executable: true, + owner: new PublicKey(0), + lamports: 0, + data, + }; + }); + const res = await removeValidatorFromPool( + connection, + stakePoolAddress, + validatorListMock.validators[0].voteAccountAddress, + ); + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2); + expect(res.instructions).toHaveLength(1); + // Make sure that the validator stake account being removed is the one we passed + const validatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + validatorListMock.validators[0].voteAccountAddress, + stakePoolAddress, + 0, + ); + expect(res.instructions[0].keys[4].pubkey).toEqual(validatorStake); + }); + }); + + describe('depositSol', () => { + const from = Keypair.generate().publicKey; + const balance = 10000; + + connection.getBalance = jest.fn(async () => balance); + + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + return >{ + executable: true, + owner: from, + lamports: balance, + data: null, + }; + }); + + it('should throw an error with invalid balance', async () => { + await expect(depositSol(connection, stakePoolAddress, from, balance + 1)).rejects.toThrow( + Error('Not enough SOL to deposit into pool. Maximum deposit amount is 0.00001 SOL.'), + ); + }); + + it('should throw an error with invalid account', async () => { + connection.getAccountInfo = jest.fn(async () => null); + await expect(depositSol(connection, stakePoolAddress, from, balance)).rejects.toThrow( + Error('Invalid stake pool account'), + ); + }); + + it('should call successfully', async () => { + connection.getAccountInfo = jest.fn(async (pubKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + return >{ + executable: true, + owner: from, + lamports: balance, + data: null, + }; + }); + + const res = await depositSol(connection, stakePoolAddress, from, balance); + + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(1); + expect(res.instructions).toHaveLength(3); + expect(res.signers).toHaveLength(1); + }); + }); + + describe('withdrawSol', () => { + const tokenOwner = new PublicKey(0); + const solReceiver = new PublicKey(1); + + it('should throw an error with invalid stake pool account', async () => { + connection.getAccountInfo = jest.fn(async () => null); + await expect( + withdrawSol(connection, stakePoolAddress, tokenOwner, solReceiver, 1), + ).rejects.toThrowError('Invalid stake pool account'); + }); + + it('should throw an error with invalid token account', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return null; + } + return null; + }); + + await expect( + withdrawSol(connection, stakePoolAddress, tokenOwner, solReceiver, 1), + ).rejects.toThrow(TokenAccountNotFoundError); + }); + + it('should throw an error with invalid token account balance', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey === stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return mockTokenAccount(0); + } + return null; + }); + + await expect( + withdrawSol(connection, stakePoolAddress, tokenOwner, solReceiver, 1), + ).rejects.toThrow( + Error( + 'Not enough token balance to withdraw 1 pool tokens.\n Maximum withdraw amount is 0 pool tokens.', + ), + ); + }); + + it('should call successfully', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return mockTokenAccount(LAMPORTS_PER_SOL); + } + return null; + }); + const res = await withdrawSol(connection, stakePoolAddress, tokenOwner, solReceiver, 1); + + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2); + expect(res.instructions).toHaveLength(2); + expect(res.signers).toHaveLength(1); + }); + }); + + describe('withdrawStake', () => { + const tokenOwner = new PublicKey(0); + + it('should throw an error with invalid token account', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + return null; + }); + + await expect(withdrawStake(connection, stakePoolAddress, tokenOwner, 1)).rejects.toThrow( + TokenAccountNotFoundError, + ); + }); + + it('should throw an error with invalid token account balance', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return mockTokenAccount(0); + } + return null; + }); + + await expect(withdrawStake(connection, stakePoolAddress, tokenOwner, 1)).rejects.toThrow( + Error( + 'Not enough token balance to withdraw 1 pool tokens.\n' + + ' Maximum withdraw amount is 0 pool tokens.', + ), + ); + }); + + it('should call successfully', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return mockTokenAccount(LAMPORTS_PER_SOL * 2); + } + if (pubKey.equals(stakePoolMock.validatorList)) { + return mockValidatorList(); + } + return null; + }); + const res = await withdrawStake(connection, stakePoolAddress, tokenOwner, 1); + + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(4); + expect(res.instructions).toHaveLength(3); + expect(res.signers).toHaveLength(2); + expect(res.stakeReceiver).toEqual(undefined); + expect(res.totalRentFreeBalances).toEqual(10000); + }); + + it('withdraw to a stake account provided', async () => { + const stakeReceiver = new PublicKey(20); + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + if (pubKey.equals(CONSTANTS.poolTokenAccount)) { + return mockTokenAccount(LAMPORTS_PER_SOL * 2); + } + if (pubKey.equals(stakePoolMock.validatorList)) { + return mockValidatorList(); + } + if (pubKey.equals(CONSTANTS.validatorStakeAccountAddress)) + return mockValidatorsStakeAccount(); + return null; + }); + connection.getParsedAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey.equals(stakeReceiver)) { + return mockRpc(stakeAccountData); + } + return null; + }); + + const res = await withdrawStake( + connection, + stakePoolAddress, + tokenOwner, + 1, + undefined, + undefined, + stakeReceiver, + ); + + expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(4); + expect((connection.getParsedAccountInfo as jest.Mock).mock.calls.length).toBe(1); + expect(res.instructions).toHaveLength(3); + expect(res.signers).toHaveLength(2); + expect(res.stakeReceiver).toEqual(stakeReceiver); + expect(res.totalRentFreeBalances).toEqual(10000); + }); + }); + describe('getStakeAccount', () => { + it('returns an uninitialized parsed stake account', async () => { + const stakeAccount = new PublicKey(20); + connection.getParsedAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey.equals(stakeAccount)) { + return mockRpc(uninitializedStakeAccount); + } + return null; + }); + const parsedStakeAccount = await getStakeAccount(connection, stakeAccount); + expect((connection.getParsedAccountInfo as jest.Mock).mock.calls.length).toBe(1); + expect(parsedStakeAccount).toEqual(uninitializedStakeAccount.parsed); + }); + }); + + describe('createPoolTokenMetadata', () => { + it('should create pool token metadata', async () => { + connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => { + if (pubKey == stakePoolAddress) { + return stakePoolAccount; + } + return null; + }); + const name = 'test'; + const symbol = 'TEST'; + const uri = 'https://example.com'; + + const payer = new PublicKey(0); + const res = await createPoolTokenMetadata( + connection, + stakePoolAddress, + payer, + name, + symbol, + uri, + ); + + const type = tokenMetadataLayout(17, name.length, symbol.length, uri.length); + const data = decodeData(type, res.instructions[0].data); + expect(Buffer.from(data.name).toString()).toBe(name); + expect(Buffer.from(data.symbol).toString()).toBe(symbol); + expect(Buffer.from(data.uri).toString()).toBe(uri); + }); + + it('should update pool token metadata', async () => { + const name = 'test'; + const symbol = 'TEST'; + const uri = 'https://example.com'; + const res = await updatePoolTokenMetadata(connection, stakePoolAddress, name, symbol, uri); + const type = tokenMetadataLayout(18, name.length, symbol.length, uri.length); + const data = decodeData(type, res.instructions[0].data); + expect(Buffer.from(data.name).toString()).toBe(name); + expect(Buffer.from(data.symbol).toString()).toBe(symbol); + expect(Buffer.from(data.uri).toString()).toBe(uri); + }); + }); +}); diff --git a/clients/js-legacy/test/layouts.test.ts b/clients/js-legacy/test/layouts.test.ts new file mode 100644 index 00000000..053f43e1 --- /dev/null +++ b/clients/js-legacy/test/layouts.test.ts @@ -0,0 +1,35 @@ +import { StakePoolLayout, ValidatorListLayout, ValidatorList } from '../src/layouts'; +import { deepStrictEqualBN } from './equal'; +import { stakePoolMock, validatorListMock } from './mocks'; + +describe('layouts', () => { + describe('StakePoolAccount', () => { + it('should successfully decode StakePoolAccount data', () => { + const encodedData = Buffer.alloc(1024); + StakePoolLayout.encode(stakePoolMock, encodedData); + const decodedData = StakePoolLayout.decode(encodedData); + deepStrictEqualBN(decodedData, stakePoolMock); + }); + }); + + describe('ValidatorListAccount', () => { + it('should successfully decode ValidatorListAccount account data', () => { + const expectedData: ValidatorList = { + accountType: 0, + maxValidators: 10, + validators: [], + }; + const encodedData = Buffer.alloc(64); + ValidatorListLayout.encode(expectedData, encodedData); + const decodedData = ValidatorListLayout.decode(encodedData); + expect(decodedData).toEqual(expectedData); + }); + + it('should successfully decode ValidatorListAccount with nonempty ValidatorInfo', () => { + const encodedData = Buffer.alloc(1024); + ValidatorListLayout.encode(validatorListMock, encodedData); + const decodedData = ValidatorListLayout.decode(encodedData); + deepStrictEqualBN(decodedData, validatorListMock); + }); + }); +}); diff --git a/clients/js-legacy/test/mocks.ts b/clients/js-legacy/test/mocks.ts new file mode 100644 index 00000000..c9c10839 --- /dev/null +++ b/clients/js-legacy/test/mocks.ts @@ -0,0 +1,217 @@ +import { AccountInfo, LAMPORTS_PER_SOL, PublicKey, StakeProgram } from '@solana/web3.js'; +import BN from 'bn.js'; +import { ValidatorStakeInfo } from '../src'; +import { AccountLayout, TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { ValidatorListLayout, ValidatorStakeInfoStatus } from '../src/layouts'; + +export const CONSTANTS = { + poolTokenAccount: new PublicKey('GQkqTamwqjaNDfsbNm7r3aXPJ4oTSqKC3d5t2PF9Smqd'), + validatorStakeAccountAddress: new PublicKey( + new BN('69184b7f1bc836271c4ac0e29e53eb38a38ea0e7bcde693c45b30d1592a5a678', 'hex'), + ), +}; + +export const stakePoolMock = { + accountType: 1, + manager: new PublicKey(11), + staker: new PublicKey(12), + stakeDepositAuthority: new PublicKey(13), + stakeWithdrawBumpSeed: 255, + validatorList: new PublicKey(14), + reserveStake: new PublicKey(15), + poolMint: new PublicKey(16), + managerFeeAccount: new PublicKey(17), + tokenProgramId: new PublicKey(18), + totalLamports: new BN(LAMPORTS_PER_SOL * 999), + poolTokenSupply: new BN(LAMPORTS_PER_SOL * 100), + lastUpdateEpoch: new BN('7c', 'hex'), + lockup: { + unixTimestamp: new BN(Date.now()), + epoch: new BN(1), + custodian: new PublicKey(0), + }, + epochFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + nextEpochFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + preferredDepositValidatorVoteAddress: new PublicKey(1), + preferredWithdrawValidatorVoteAddress: new PublicKey(2), + stakeDepositFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + stakeWithdrawalFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + nextStakeWithdrawalFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + stakeReferralFee: 0, + solDepositAuthority: new PublicKey(0), + solDepositFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + solReferralFee: 0, + solWithdrawAuthority: new PublicKey(0), + solWithdrawalFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + nextSolWithdrawalFee: { + denominator: new BN(0), + numerator: new BN(0), + }, + lastEpochPoolTokenSupply: new BN(0), + lastEpochTotalLamports: new BN(0), +}; + +export const validatorListMock = { + accountType: 0, + maxValidators: 100, + validators: [ + { + status: ValidatorStakeInfoStatus.ReadyForRemoval, + voteAccountAddress: new PublicKey( + new BN('a9946a889af14fd3c9b33d5df309489d9699271a6b09ff3190fcb41cf21a2f8c', 'hex'), + ), + lastUpdateEpoch: new BN('c3', 'hex'), + activeStakeLamports: new BN(123), + transientStakeLamports: new BN(999), + transientSeedSuffixStart: new BN(999), + transientSeedSuffixEnd: new BN(999), + }, + { + status: ValidatorStakeInfoStatus.Active, + voteAccountAddress: new PublicKey( + new BN('3796d40645ee07e3c64117e3f73430471d4c40465f696ebc9b034c1fc06a9f7d', 'hex'), + ), + lastUpdateEpoch: new BN('c3', 'hex'), + activeStakeLamports: new BN(LAMPORTS_PER_SOL * 100), + transientStakeLamports: new BN(22), + transientSeedSuffixStart: new BN(0), + transientSeedSuffixEnd: new BN(0), + }, + { + status: ValidatorStakeInfoStatus.Active, + voteAccountAddress: new PublicKey( + new BN('e4e37d6f2e80c0bb0f3da8a06304e57be5cda6efa2825b86780aa320d9784cf8', 'hex'), + ), + lastUpdateEpoch: new BN('c3', 'hex'), + activeStakeLamports: new BN(0), + transientStakeLamports: new BN(0), + transientSeedSuffixStart: new BN('a', 'hex'), + transientSeedSuffixEnd: new BN('a', 'hex'), + }, + ], +}; + +export function mockTokenAccount(amount = 0) { + const data = Buffer.alloc(165); + AccountLayout.encode( + { + mint: stakePoolMock.poolMint, + owner: new PublicKey(0), + amount: BigInt(amount), + delegateOption: 0, + delegate: new PublicKey(0), + delegatedAmount: BigInt(0), + state: 1, + isNativeOption: 0, + isNative: BigInt(0), + closeAuthorityOption: 0, + closeAuthority: new PublicKey(0), + }, + data, + ); + + return >{ + executable: true, + owner: TOKEN_PROGRAM_ID, + lamports: amount, + data, + }; +} + +export const mockRpc = (data: any): any => { + const value = { + owner: StakeProgram.programId, + lamports: LAMPORTS_PER_SOL, + data: data, + executable: false, + rentEpoch: 0, + }; + return { + context: { + slot: 11, + }, + value: value, + }; +}; + +export const stakeAccountData = { + program: 'stake', + parsed: { + type: 'delegated', + info: { + meta: { + rentExemptReserve: new BN(1), + lockup: { + epoch: 32, + unixTimestamp: 2, + custodian: new PublicKey(12), + }, + authorized: { + staker: new PublicKey(12), + withdrawer: new PublicKey(12), + }, + }, + stake: { + delegation: { + voter: new PublicKey( + new BN('e4e37d6f2e80c0bb0f3da8a06304e57be5cda6efa2825b86780aa320d9784cf8', 'hex'), + ), + stake: new BN(0), + activationEpoch: new BN(1), + deactivationEpoch: new BN(1), + warmupCooldownRate: 1.2, + }, + creditsObserved: 1, + }, + }, + }, +}; + +export const uninitializedStakeAccount = { + program: 'stake', + parsed: { + type: 'uninitialized', + }, +}; + +export function mockValidatorsStakeAccount() { + const data = Buffer.alloc(1024); + return >{ + executable: false, + owner: StakeProgram.programId, + lamports: 3000000000, + data, + }; +} + +export function mockValidatorList() { + const data = Buffer.alloc(1024); + ValidatorListLayout.encode(validatorListMock, data); + return >{ + executable: true, + owner: new PublicKey(0), + lamports: 0, + data, + }; +} diff --git a/clients/js-legacy/tsconfig.json b/clients/js-legacy/tsconfig.json new file mode 100644 index 00000000..2ae40abe --- /dev/null +++ b/clients/js-legacy/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "es2019", + "baseUrl": "./src", + "outDir": "dist", + "declaration": true, + "declarationDir": "dist", + "emitDeclarationOnly": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "skipLibCheck": true // needed to avoid re-export errors from borsh + }, + "include": ["src/**/*.ts"] +} diff --git a/clients/py/.flake8 b/clients/py/.flake8 new file mode 100644 index 00000000..aa079ec5 --- /dev/null +++ b/clients/py/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length=120 diff --git a/clients/py/.gitignore b/clients/py/.gitignore new file mode 100644 index 00000000..ad0e7548 --- /dev/null +++ b/clients/py/.gitignore @@ -0,0 +1,7 @@ +# python cache files +*__pycache__* +.pytest_cache +.mypy_cache + +# venv +venv/ diff --git a/clients/py/LICENSE b/clients/py/LICENSE new file mode 100644 index 00000000..7e71f6e9 --- /dev/null +++ b/clients/py/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 Solana Foundation. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/clients/py/README.md b/clients/py/README.md new file mode 100644 index 00000000..cda7beea --- /dev/null +++ b/clients/py/README.md @@ -0,0 +1,68 @@ +# Stake-Pool Python Bindings + +Preliminary Python bindings to interact with the stake pool program, enabling +simple stake delegation bots. + +## To do + +* More reference bot implementations +* Add bindings for all stake pool instructions, see `TODO`s in `stake_pool/instructions.py` +* Finish bindings for vote and stake program +* Upstream vote and stake program bindings to https://github.com/michaelhly/solana-py + +## Development + +### Environment Setup + +1. Ensure that Python 3 is installed with `venv`: https://www.python.org/downloads/ +2. (Optional, but highly recommended) Setup and activate a virtual environment: + +``` +$ python3 -m venv venv +$ source venv/bin/activate +``` + +3. Install build and dev requirements + +``` +$ pip install -r requirements.txt +$ pip install -r optional-requirements.txt +``` + +4. Install the Solana tool suite: https://docs.solana.com/cli/install-solana-cli-tools + +### Test + +Testing through `pytest`: + +``` +$ python3 -m pytest +``` + +Note: the tests all run against a `solana-test-validator` with short epochs of 64 +slots (25.6 seconds exactly). Some tests wait for epoch changes, so they take +time, roughly 90 seconds total at the time of this writing. + +### Formatting + +``` +$ flake8 bot spl_token stake stake_pool system tests vote +``` + +### Type Checker + +``` +$ mypy bot stake stake_pool tests vote spl_token system +``` + +## Delegation Bots + +The `./bot` directory contains sample stake pool delegation bot implementations: + +* `rebalance`: simple bot to make the amount delegated to each validator +uniform, while also maintaining some SOL in the reserve if desired. Can be run +with the stake pool address, staker keypair, and SOL to leave in the reserve: + +``` +$ python3 bot/rebalance.py Zg5YBPAk8RqBR9kaLLSoN5C8Uv7nErBz1WC63HTsCPR staker.json 10.5 +``` diff --git a/clients/py/bot/__init__.py b/clients/py/bot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/bot/rebalance.py b/clients/py/bot/rebalance.py new file mode 100644 index 00000000..e12a6a9a --- /dev/null +++ b/clients/py/bot/rebalance.py @@ -0,0 +1,132 @@ +import argparse +import asyncio +import json + +from solders.keypair import Keypair +from solders.pubkey import Pubkey +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed + +from stake.constants import STAKE_LEN, LAMPORTS_PER_SOL +from stake_pool.actions import decrease_validator_stake, increase_validator_stake, update_stake_pool +from stake_pool.constants import MINIMUM_ACTIVE_STAKE +from stake_pool.state import StakePool, ValidatorList + + +async def get_client(endpoint: str) -> AsyncClient: + print(f'Connecting to network at {endpoint}') + async_client = AsyncClient(endpoint=endpoint, commitment=Confirmed) + total_attempts = 10 + current_attempt = 0 + while not await async_client.is_connected(): + if current_attempt == total_attempts: + raise Exception("Could not connect to test validator") + else: + current_attempt += 1 + await asyncio.sleep(1) + return async_client + + +async def rebalance(endpoint: str, stake_pool_address: Pubkey, staker: Keypair, retained_reserve_amount: float): + async_client = await get_client(endpoint) + + epoch_resp = await async_client.get_epoch_info(commitment=Confirmed) + epoch = epoch_resp.value.epoch + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + print(f'Stake pool last update epoch {stake_pool.last_update_epoch}, current epoch {epoch}') + if stake_pool.last_update_epoch != epoch: + print('Updating stake pool') + await update_stake_pool(async_client, staker, stake_pool_address) + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + rent_resp = await async_client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = rent_resp.value + retained_reserve_lamports = int(retained_reserve_amount * LAMPORTS_PER_SOL) + + val_resp = await async_client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = val_resp.value.data if val_resp.value else bytes() + validator_list = ValidatorList.decode(data) + + print('Stake pool stats:') + print(f'* {stake_pool.total_lamports} total lamports') + num_validators = len(validator_list.validators) + print(f'* {num_validators} validators') + print(f'* Retaining {retained_reserve_lamports} lamports in the reserve') + lamports_per_validator = (stake_pool.total_lamports - retained_reserve_lamports) // num_validators + num_increases = sum([ + 1 for validator in validator_list.validators + if validator.transient_stake_lamports == 0 and validator.active_stake_lamports < lamports_per_validator + ]) + total_usable_lamports = stake_pool.total_lamports - retained_reserve_lamports - num_increases * stake_rent_exemption + lamports_per_validator = total_usable_lamports // num_validators + print(f'* {lamports_per_validator} lamports desired per validator') + + futures = [] + for validator in validator_list.validators: + if validator.transient_stake_lamports != 0: + print(f'Skipping {validator.vote_account_address}: {validator.transient_stake_lamports} transient lamports') + else: + if validator.active_stake_lamports > lamports_per_validator: + lamports_to_decrease = validator.active_stake_lamports - lamports_per_validator + if lamports_to_decrease <= stake_rent_exemption: + print(f'Skipping decrease on {validator.vote_account_address}, \ +currently at {validator.active_stake_lamports} lamports, \ +decrease of {lamports_to_decrease} below the rent exmption') + else: + futures.append(decrease_validator_stake( + async_client, staker, staker, stake_pool_address, + validator.vote_account_address, lamports_to_decrease + )) + elif validator.active_stake_lamports < lamports_per_validator: + lamports_to_increase = lamports_per_validator - validator.active_stake_lamports + if lamports_to_increase < MINIMUM_ACTIVE_STAKE: + print(f'Skipping increase on {validator.vote_account_address}, \ +currently at {validator.active_stake_lamports} lamports, \ +increase of {lamports_to_increase} less than the minimum of {MINIMUM_ACTIVE_STAKE}') + else: + futures.append(increase_validator_stake( + async_client, staker, staker, stake_pool_address, + validator.vote_account_address, lamports_to_increase + )) + else: + print(f'{validator.vote_account_address}: already at {lamports_per_validator}') + + print('Executing strategy') + await asyncio.gather(*futures) + print('Done') + await async_client.close() + + +def keypair_from_file(keyfile_name: str) -> Keypair: + with open(keyfile_name, 'r') as keyfile: + data = keyfile.read() + int_list = json.loads(data) + bytes_list = [value.to_bytes(1, 'little') for value in int_list] + return Keypair.from_seed(b''.join(bytes_list)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Rebalance stake evenly between all the validators in a stake pool.') + parser.add_argument('stake_pool', metavar='STAKE_POOL_ADDRESS', type=str, + help='Stake pool to rebalance, given by a public key in base-58,\ + e.g. Zg5YBPAk8RqBR9kaLLSoN5C8Uv7nErBz1WC63HTsCPR') + parser.add_argument('staker', metavar='STAKER_KEYPAIR', type=str, + help='Staker for the stake pool, given by a keypair file, e.g. staker.json') + parser.add_argument('reserve_amount', metavar='RESERVE_AMOUNT', type=float, + help='Amount of SOL to keep in the reserve, e.g. 10.5') + parser.add_argument('--endpoint', metavar='ENDPOINT_URL', type=str, + default='https://api.mainnet-beta.solana.com', + help='RPC endpoint to use, e.g. https://api.mainnet-beta.solana.com') + + args = parser.parse_args() + stake_pool = Pubkey(args.stake_pool) + staker = keypair_from_file(args.staker) + print(f'Rebalancing stake pool {stake_pool}') + print(f'Staker public key: {staker.pubkey()}') + print(f'Amount to leave in the reserve: {args.reserve_amount} SOL') + asyncio.run(rebalance(args.endpoint, stake_pool, staker, args.reserve_amount)) diff --git a/clients/py/optional-requirements.txt b/clients/py/optional-requirements.txt new file mode 100644 index 00000000..5f08be95 --- /dev/null +++ b/clients/py/optional-requirements.txt @@ -0,0 +1,11 @@ +flake8==7.1.0 +iniconfig==2.0.0 +mccabe==0.7.0 +mypy==1.11.0 +mypy-extensions==1.0.0 +packaging==24.1 +pluggy==1.5.0 +pycodestyle==2.12.0 +pyflakes==3.2.0 +pytest==8.3.1 +pytest-asyncio==0.23.8 diff --git a/clients/py/requirements.txt b/clients/py/requirements.txt new file mode 100644 index 00000000..9b0f39a3 --- /dev/null +++ b/clients/py/requirements.txt @@ -0,0 +1,14 @@ +anyio==4.4.0 +certifi==2024.7.4 +construct==2.10.68 +construct-typing==0.5.6 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.7 +jsonalias==0.1.1 +sniffio==1.3.1 +solana==0.34.2 +solders==0.21.0 +typing_extensions==4.12.2 +websockets==11.0.3 diff --git a/clients/py/spl_token/__init__.py b/clients/py/spl_token/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/spl_token/actions.py b/clients/py/spl_token/actions.py new file mode 100644 index 00000000..99a03fd0 --- /dev/null +++ b/clients/py/spl_token/actions.py @@ -0,0 +1,62 @@ +from solders.pubkey import Pubkey +from solders.keypair import Keypair +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.types import TxOpts +from solana.transaction import Transaction +import solders.system_program as sys + +from spl.token.constants import TOKEN_PROGRAM_ID +from spl.token.async_client import AsyncToken +from spl.token._layouts import MINT_LAYOUT +import spl.token.instructions as spl_token + + +OPTS = TxOpts(skip_confirmation=False, preflight_commitment=Confirmed) + + +async def create_associated_token_account( + client: AsyncClient, + payer: Keypair, + owner: Pubkey, + mint: Pubkey +) -> Pubkey: + txn = Transaction(fee_payer=payer.pubkey()) + create_txn = spl_token.create_associated_token_account( + payer=payer.pubkey(), owner=owner, mint=mint + ) + txn.add(create_txn) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, opts=OPTS) + return create_txn.accounts[1].pubkey + + +async def create_mint(client: AsyncClient, payer: Keypair, mint: Keypair, mint_authority: Pubkey): + mint_balance = await AsyncToken.get_min_balance_rent_for_exempt_for_mint(client) + print(f"Creating pool token mint {mint.pubkey()}") + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=payer.pubkey(), + to_pubkey=mint.pubkey(), + lamports=mint_balance, + space=MINT_LAYOUT.sizeof(), + owner=TOKEN_PROGRAM_ID, + ) + ) + ) + txn.add( + spl_token.initialize_mint( + spl_token.InitializeMintParams( + program_id=TOKEN_PROGRAM_ID, + mint=mint.pubkey(), + decimals=9, + mint_authority=mint_authority, + freeze_authority=None, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction( + txn, payer, mint, recent_blockhash=recent_blockhash, opts=OPTS) diff --git a/clients/py/stake/__init__.py b/clients/py/stake/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/stake/actions.py b/clients/py/stake/actions.py new file mode 100644 index 00000000..1147a56d --- /dev/null +++ b/clients/py/stake/actions.py @@ -0,0 +1,91 @@ +from solders.pubkey import Pubkey +from solders.keypair import Keypair +import solders.system_program as sys +from solana.constants import SYSTEM_PROGRAM_ID +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.types import TxOpts +from solders.sysvar import CLOCK, STAKE_HISTORY +from solana.transaction import Transaction + +from stake.constants import STAKE_LEN, STAKE_PROGRAM_ID, SYSVAR_STAKE_CONFIG_ID +from stake.state import Authorized, Lockup, StakeAuthorize +import stake.instructions as st + + +OPTS = TxOpts(skip_confirmation=False, preflight_commitment=Confirmed) + + +async def create_stake(client: AsyncClient, payer: Keypair, stake: Keypair, authority: Pubkey, lamports: int): + print(f"Creating stake {stake.pubkey()}") + resp = await client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=payer.pubkey(), + to_pubkey=stake.pubkey(), + lamports=resp.value + lamports, + space=STAKE_LEN, + owner=STAKE_PROGRAM_ID, + ) + ) + ) + txn.add( + st.initialize( + st.InitializeParams( + stake=stake.pubkey(), + authorized=Authorized( + staker=authority, + withdrawer=authority, + ), + lockup=Lockup( + unix_timestamp=0, + epoch=0, + custodian=SYSTEM_PROGRAM_ID, + ) + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, stake, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def delegate_stake(client: AsyncClient, payer: Keypair, staker: Keypair, stake: Pubkey, vote: Pubkey): + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + st.delegate_stake( + st.DelegateStakeParams( + stake=stake, + vote=vote, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + stake_config_id=SYSVAR_STAKE_CONFIG_ID, + staker=staker.pubkey(), + ) + ) + ) + signers = [payer, staker] if payer.pubkey() != staker.pubkey() else [payer] + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, *signers, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def authorize( + client: AsyncClient, payer: Keypair, authority: Keypair, stake: Pubkey, + new_authority: Pubkey, stake_authorize: StakeAuthorize +): + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + st.authorize( + st.AuthorizeParams( + stake=stake, + clock_sysvar=CLOCK, + authority=authority.pubkey(), + new_authority=new_authority, + stake_authorize=stake_authorize, + ) + ) + ) + signers = [payer, authority] if payer.pubkey() != authority.pubkey() else [payer] + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, *signers, recent_blockhash=recent_blockhash, opts=OPTS) diff --git a/clients/py/stake/constants.py b/clients/py/stake/constants.py new file mode 100644 index 00000000..da1c644c --- /dev/null +++ b/clients/py/stake/constants.py @@ -0,0 +1,18 @@ +"""Stake Program Constants.""" + +from solders.pubkey import Pubkey + +STAKE_PROGRAM_ID = Pubkey.from_string("Stake11111111111111111111111111111111111111") +"""Public key that identifies the Stake program.""" + +SYSVAR_STAKE_CONFIG_ID = Pubkey.from_string("StakeConfig11111111111111111111111111111111") +"""Public key that identifies the Stake config sysvar.""" + +STAKE_LEN: int = 200 +"""Size of stake account.""" + +LAMPORTS_PER_SOL: int = 1_000_000_000 +"""Number of lamports per SOL""" + +MINIMUM_DELEGATION: int = LAMPORTS_PER_SOL +"""Minimum delegation allowed by the stake program""" diff --git a/clients/py/stake/instructions.py b/clients/py/stake/instructions.py new file mode 100644 index 00000000..91e7e7dc --- /dev/null +++ b/clients/py/stake/instructions.py @@ -0,0 +1,178 @@ +"""Stake Program Instructions.""" + +from enum import IntEnum +from typing import NamedTuple + +from construct import Switch # type: ignore +from construct import Int32ul, Pass # type: ignore +from construct import Bytes, Struct + +from solders.pubkey import Pubkey +from solders.sysvar import RENT +from solders.instruction import AccountMeta, Instruction + +from stake.constants import STAKE_PROGRAM_ID +from stake.state import AUTHORIZED_LAYOUT, LOCKUP_LAYOUT, Authorized, Lockup, StakeAuthorize + +PUBLIC_KEY_LAYOUT = Bytes(32) + + +class InitializeParams(NamedTuple): + """Initialize stake transaction params.""" + + stake: Pubkey + """`[w]` Uninitialized stake account.""" + authorized: Authorized + """Information about the staker and withdrawer keys.""" + lockup: Lockup + """Stake lockup, if any.""" + + +class DelegateStakeParams(NamedTuple): + """Initialize stake transaction params.""" + + stake: Pubkey + """`[w]` Uninitialized stake account.""" + vote: Pubkey + """`[]` Vote account to which this stake will be delegated.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + stake_history_sysvar: Pubkey + """`[]` Stake history sysvar that carries stake warmup/cooldown history.""" + stake_config_id: Pubkey + """`[]` Address of config account that carries stake config.""" + staker: Pubkey + """`[s]` Stake authority.""" + + +class AuthorizeParams(NamedTuple): + """Authorize stake transaction params.""" + + stake: Pubkey + """`[w]` Initialized stake account to modify.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + authority: Pubkey + """`[s]` Current stake authority.""" + + # Params + new_authority: Pubkey + """New authority's public key.""" + stake_authorize: StakeAuthorize + """Type of authority to modify, staker or withdrawer.""" + + +class InstructionType(IntEnum): + """Stake Instruction Types.""" + + INITIALIZE = 0 + AUTHORIZE = 1 + DELEGATE_STAKE = 2 + SPLIT = 3 + WITHDRAW = 4 + DEACTIVATE = 5 + SET_LOCKUP = 6 + MERGE = 7 + AUTHORIZE_WITH_SEED = 8 + INITIALIZE_CHECKED = 9 + AUTHORIZED_CHECKED = 10 + AUTHORIZED_CHECKED_WITH_SEED = 11 + SET_LOCKUP_CHECKED = 12 + + +INITIALIZE_LAYOUT = Struct( + "authorized" / AUTHORIZED_LAYOUT, + "lockup" / LOCKUP_LAYOUT, +) + + +AUTHORIZE_LAYOUT = Struct( + "new_authority" / PUBLIC_KEY_LAYOUT, + "stake_authorize" / Int32ul, +) + + +INSTRUCTIONS_LAYOUT = Struct( + "instruction_type" / Int32ul, + "args" + / Switch( + lambda this: this.instruction_type, + { + InstructionType.INITIALIZE: INITIALIZE_LAYOUT, + InstructionType.AUTHORIZE: AUTHORIZE_LAYOUT, + InstructionType.DELEGATE_STAKE: Pass, + InstructionType.SPLIT: Pass, + InstructionType.WITHDRAW: Pass, + InstructionType.DEACTIVATE: Pass, + InstructionType.SET_LOCKUP: Pass, + InstructionType.MERGE: Pass, + InstructionType.AUTHORIZE_WITH_SEED: Pass, + InstructionType.INITIALIZE_CHECKED: Pass, + InstructionType.AUTHORIZED_CHECKED: Pass, + InstructionType.AUTHORIZED_CHECKED_WITH_SEED: Pass, + InstructionType.SET_LOCKUP_CHECKED: Pass, + }, + ), +) + + +def initialize(params: InitializeParams) -> Instruction: + """Creates a transaction instruction to initialize a new stake.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=RENT, is_signer=False, is_writable=False), + ], + program_id=STAKE_PROGRAM_ID, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.INITIALIZE, + args=dict( + authorized=params.authorized.as_bytes_dict(), + lockup=params.lockup.as_bytes_dict(), + ), + ) + ) + ) + + +def delegate_stake(params: DelegateStakeParams) -> Instruction: + """Creates an instruction to delegate a stake account.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.vote, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_config_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + ], + program_id=STAKE_PROGRAM_ID, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DELEGATE_STAKE, + args=None, + ) + ) + ) + + +def authorize(params: AuthorizeParams) -> Instruction: + """Creates an instruction to change the authority on a stake account.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.authority, is_signer=True, is_writable=False), + ], + program_id=STAKE_PROGRAM_ID, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.AUTHORIZE, + args={ + 'new_authority': bytes(params.new_authority), + 'stake_authorize': params.stake_authorize, + }, + ) + ) + ) diff --git a/clients/py/stake/state.py b/clients/py/stake/state.py new file mode 100644 index 00000000..45d1942a --- /dev/null +++ b/clients/py/stake/state.py @@ -0,0 +1,130 @@ +"""Stake State.""" + +from enum import IntEnum +from typing import NamedTuple, Dict +from construct import Bytes, Container, Struct, Float64l, Int32ul, Int64ul # type: ignore + +from solders.pubkey import Pubkey + +PUBLIC_KEY_LAYOUT = Bytes(32) + + +class Lockup(NamedTuple): + """Lockup for a stake account.""" + unix_timestamp: int + epoch: int + custodian: Pubkey + + @classmethod + def decode_container(cls, container: Container): + return Lockup( + unix_timestamp=container['unix_timestamp'], + epoch=container['epoch'], + custodian=Pubkey(container['custodian']), + ) + + def as_bytes_dict(self) -> Dict: + self_dict = self._asdict() + self_dict['custodian'] = bytes(self_dict['custodian']) + return self_dict + + +class Authorized(NamedTuple): + """Define who is authorized to change a stake.""" + staker: Pubkey + withdrawer: Pubkey + + def as_bytes_dict(self) -> Dict: + return { + 'staker': bytes(self.staker), + 'withdrawer': bytes(self.withdrawer), + } + + +class StakeAuthorize(IntEnum): + """Stake Authorization Types.""" + STAKER = 0 + WITHDRAWER = 1 + + +class StakeStakeType(IntEnum): + """Stake State Types.""" + UNINITIALIZED = 0 + INITIALIZED = 1 + STAKE = 2 + REWARDS_POOL = 3 + + +class StakeStake(NamedTuple): + state_type: StakeStakeType + state: Container + + """Stake state.""" + @classmethod + def decode(cls, data: bytes): + parsed = STAKE_STATE_LAYOUT.parse(data) + return StakeStake( + state_type=parsed['state_type'], + state=parsed['state'], + ) + + +LOCKUP_LAYOUT = Struct( + "unix_timestamp" / Int64ul, + "epoch" / Int64ul, + "custodian" / PUBLIC_KEY_LAYOUT, +) + + +AUTHORIZED_LAYOUT = Struct( + "staker" / PUBLIC_KEY_LAYOUT, + "withdrawer" / PUBLIC_KEY_LAYOUT, +) + +META_LAYOUT = Struct( + "rent_exempt_reserve" / Int64ul, + "authorized" / AUTHORIZED_LAYOUT, + "lockup" / LOCKUP_LAYOUT, +) + +META_LAYOUT = Struct( + "rent_exempt_reserve" / Int64ul, + "authorized" / AUTHORIZED_LAYOUT, + "lockup" / LOCKUP_LAYOUT, +) + +DELEGATION_LAYOUT = Struct( + "voter_pubkey" / PUBLIC_KEY_LAYOUT, + "stake" / Int64ul, + "activation_epoch" / Int64ul, + "deactivation_epoch" / Int64ul, + "warmup_cooldown_rate" / Float64l, +) + +STAKE_LAYOUT = Struct( + "delegation" / DELEGATION_LAYOUT, + "credits_observed" / Int64ul, +) + +STAKE_AND_META_LAYOUT = Struct( + "meta" / META_LAYOUT, + "stake" / STAKE_LAYOUT, +) + +STAKE_STATE_LAYOUT = Struct( + "state_type" / Int32ul, + "state" / STAKE_AND_META_LAYOUT, + # NOTE: This can be done better, but was mainly needed for testing. Ideally, + # we would have something like: + # + # Switch( + # lambda this: this.state, + # { + # StakeStakeType.UNINITIALIZED: Pass, + # StakeStakeType.INITIALIZED: META_LAYOUT, + # StakeStakeType.STAKE: STAKE_AND_META_LAYOUT, + # } + # ), + # + # Unfortunately, it didn't work. +) diff --git a/clients/py/stake_pool/__init__.py b/clients/py/stake_pool/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/stake_pool/actions.py b/clients/py/stake_pool/actions.py new file mode 100644 index 00000000..400f9252 --- /dev/null +++ b/clients/py/stake_pool/actions.py @@ -0,0 +1,753 @@ +from typing import Optional, Tuple + +from solders.keypair import Keypair +from solders.pubkey import Pubkey +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.types import TxOpts +from solders.sysvar import CLOCK, RENT, STAKE_HISTORY +from solana.transaction import Transaction +import solders.system_program as sys + +from spl.token.constants import TOKEN_PROGRAM_ID + +from stake.constants import STAKE_PROGRAM_ID, STAKE_LEN, SYSVAR_STAKE_CONFIG_ID +import stake.instructions as st +from stake.state import StakeAuthorize +from stake_pool.constants import \ + MAX_VALIDATORS_TO_UPDATE, \ + MINIMUM_RESERVE_LAMPORTS, \ + STAKE_POOL_PROGRAM_ID, \ + METADATA_PROGRAM_ID, \ + find_stake_program_address, \ + find_transient_stake_program_address, \ + find_withdraw_authority_program_address, \ + find_metadata_account, \ + find_ephemeral_stake_program_address +from stake_pool.state import STAKE_POOL_LAYOUT, ValidatorList, Fee, StakePool +import stake_pool.instructions as sp + +from stake.actions import create_stake +from spl_token.actions import create_mint, create_associated_token_account + + +OPTS = TxOpts(skip_confirmation=False, preflight_commitment=Confirmed) + + +async def create(client: AsyncClient, manager: Keypair, + stake_pool: Keypair, validator_list: Keypair, + pool_mint: Pubkey, reserve_stake: Pubkey, + manager_fee_account: Pubkey, fee: Fee, referral_fee: int): + resp = await client.get_minimum_balance_for_rent_exemption(STAKE_POOL_LAYOUT.sizeof()) + pool_balance = resp.value + txn = Transaction(fee_payer=manager.pubkey()) + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=manager.pubkey(), + to_pubkey=stake_pool.pubkey(), + lamports=pool_balance, + space=STAKE_POOL_LAYOUT.sizeof(), + owner=STAKE_POOL_PROGRAM_ID, + ) + ) + ) + max_validators = 2950 # current supported max by the program, go big! + validator_list_size = ValidatorList.calculate_validator_list_size(max_validators) + resp = await client.get_minimum_balance_for_rent_exemption(validator_list_size) + validator_list_balance = resp.value + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=manager.pubkey(), + to_pubkey=validator_list.pubkey(), + lamports=validator_list_balance, + space=validator_list_size, + owner=STAKE_POOL_PROGRAM_ID, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction( + txn, manager, stake_pool, validator_list, recent_blockhash=recent_blockhash, opts=OPTS) + + (withdraw_authority, seed) = find_withdraw_authority_program_address( + STAKE_POOL_PROGRAM_ID, stake_pool.pubkey()) + txn = Transaction(fee_payer=manager.pubkey()) + txn.add( + sp.initialize( + sp.InitializeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool.pubkey(), + manager=manager.pubkey(), + staker=manager.pubkey(), + withdraw_authority=withdraw_authority, + validator_list=validator_list.pubkey(), + reserve_stake=reserve_stake, + pool_mint=pool_mint, + manager_fee_account=manager_fee_account, + token_program_id=TOKEN_PROGRAM_ID, + epoch_fee=fee, + withdrawal_fee=fee, + deposit_fee=fee, + referral_fee=referral_fee, + max_validators=max_validators, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, manager, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def create_all( + client: AsyncClient, manager: Keypair, fee: Fee, referral_fee: int +) -> Tuple[Pubkey, Pubkey, Pubkey]: + stake_pool = Keypair() + validator_list = Keypair() + (pool_withdraw_authority, seed) = find_withdraw_authority_program_address( + STAKE_POOL_PROGRAM_ID, stake_pool.pubkey()) + + reserve_stake = Keypair() + await create_stake(client, manager, reserve_stake, pool_withdraw_authority, MINIMUM_RESERVE_LAMPORTS) + + pool_mint = Keypair() + await create_mint(client, manager, pool_mint, pool_withdraw_authority) + + manager_fee_account = await create_associated_token_account( + client, + manager, + manager.pubkey(), + pool_mint.pubkey(), + ) + + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await create( + client, manager, stake_pool, validator_list, pool_mint.pubkey(), + reserve_stake.pubkey(), manager_fee_account, fee, referral_fee) + return (stake_pool.pubkey(), validator_list.pubkey(), pool_mint.pubkey()) + + +async def add_validator_to_pool( + client: AsyncClient, staker: Keypair, + stake_pool_address: Pubkey, validator: Pubkey +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + txn = Transaction(fee_payer=staker.pubkey()) + txn.add( + sp.add_validator_to_pool_with_vote( + STAKE_POOL_PROGRAM_ID, + stake_pool_address, + stake_pool.staker, + stake_pool.validator_list, + stake_pool.reserve_stake, + validator, + None, + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, staker, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def remove_validator_from_pool( + client: AsyncClient, staker: Keypair, + stake_pool_address: Pubkey, validator: Pubkey +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + validator_info = next(x for x in validator_list.validators if x.vote_account_address == validator) + txn = Transaction(fee_payer=staker.pubkey()) + txn.add( + sp.remove_validator_from_pool_with_vote( + STAKE_POOL_PROGRAM_ID, + stake_pool_address, + stake_pool.staker, + stake_pool.validator_list, + validator, + validator_info.validator_seed_suffix or None, + validator_info.transient_seed_suffix, + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, staker, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def deposit_sol( + client: AsyncClient, funder: Keypair, stake_pool_address: Pubkey, + destination_token_account: Pubkey, amount: int, +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + (withdraw_authority, seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + + txn = Transaction(fee_payer=funder.pubkey()) + txn.add( + sp.deposit_sol( + sp.DepositSolParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + withdraw_authority=withdraw_authority, + reserve_stake=stake_pool.reserve_stake, + funding_account=funder.pubkey(), + destination_pool_account=destination_token_account, + manager_fee_account=stake_pool.manager_fee_account, + referral_pool_account=destination_token_account, + pool_mint=stake_pool.pool_mint, + system_program_id=sys.ID, + token_program_id=stake_pool.token_program_id, + amount=amount, + deposit_authority=None, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, funder, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def withdraw_sol( + client: AsyncClient, owner: Keypair, source_token_account: Pubkey, + stake_pool_address: Pubkey, destination_system_account: Pubkey, amount: int, +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + (withdraw_authority, seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + + txn = Transaction(fee_payer=owner.pubkey()) + txn.add( + sp.withdraw_sol( + sp.WithdrawSolParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + withdraw_authority=withdraw_authority, + source_transfer_authority=owner.pubkey(), + source_pool_account=source_token_account, + reserve_stake=stake_pool.reserve_stake, + destination_system_account=destination_system_account, + manager_fee_account=stake_pool.manager_fee_account, + pool_mint=stake_pool.pool_mint, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + stake_program_id=STAKE_PROGRAM_ID, + token_program_id=stake_pool.token_program_id, + amount=amount, + sol_withdraw_authority=None, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, owner, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def deposit_stake( + client: AsyncClient, + deposit_stake_authority: Keypair, + stake_pool_address: Pubkey, + validator_vote: Pubkey, + deposit_stake: Pubkey, + destination_pool_account: Pubkey, +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + + validator_info = next(x for x in validator_list.validators if x.vote_account_address == validator_vote) + + (withdraw_authority, _) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + (validator_stake, _) = find_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_vote, + stake_pool_address, + validator_info.validator_seed_suffix or None, + ) + + txn = Transaction(fee_payer=deposit_stake_authority.pubkey()) + txn.add( + st.authorize( + st.AuthorizeParams( + stake=deposit_stake, + clock_sysvar=CLOCK, + authority=deposit_stake_authority.pubkey(), + new_authority=stake_pool.stake_deposit_authority, + stake_authorize=StakeAuthorize.STAKER, + ) + ) + ) + txn.add( + st.authorize( + st.AuthorizeParams( + stake=deposit_stake, + clock_sysvar=CLOCK, + authority=deposit_stake_authority.pubkey(), + new_authority=stake_pool.stake_deposit_authority, + stake_authorize=StakeAuthorize.WITHDRAWER, + ) + ) + ) + txn.add( + sp.deposit_stake( + sp.DepositStakeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + validator_list=stake_pool.validator_list, + deposit_authority=stake_pool.stake_deposit_authority, + withdraw_authority=withdraw_authority, + deposit_stake=deposit_stake, + validator_stake=validator_stake, + reserve_stake=stake_pool.reserve_stake, + destination_pool_account=destination_pool_account, + manager_fee_account=stake_pool.manager_fee_account, + referral_pool_account=destination_pool_account, + pool_mint=stake_pool.pool_mint, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + token_program_id=stake_pool.token_program_id, + stake_program_id=STAKE_PROGRAM_ID, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, deposit_stake_authority, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def withdraw_stake( + client: AsyncClient, + payer: Keypair, + source_transfer_authority: Keypair, + destination_stake: Keypair, + stake_pool_address: Pubkey, + validator_vote: Pubkey, + destination_stake_authority: Pubkey, + source_pool_account: Pubkey, + amount: int, +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + + validator_info = next(x for x in validator_list.validators if x.vote_account_address == validator_vote) + + (withdraw_authority, _) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + (validator_stake, _) = find_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_vote, + stake_pool_address, + validator_info.validator_seed_suffix or None, + ) + + rent_resp = await client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = rent_resp.value + + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=payer.pubkey(), + to_pubkey=destination_stake.pubkey(), + lamports=stake_rent_exemption, + space=STAKE_LEN, + owner=STAKE_PROGRAM_ID, + ) + ) + ) + txn.add( + sp.withdraw_stake( + sp.WithdrawStakeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + validator_list=stake_pool.validator_list, + withdraw_authority=withdraw_authority, + validator_stake=validator_stake, + destination_stake=destination_stake.pubkey(), + destination_stake_authority=destination_stake_authority, + source_transfer_authority=source_transfer_authority.pubkey(), + source_pool_account=source_pool_account, + manager_fee_account=stake_pool.manager_fee_account, + pool_mint=stake_pool.pool_mint, + clock_sysvar=CLOCK, + token_program_id=stake_pool.token_program_id, + stake_program_id=STAKE_PROGRAM_ID, + amount=amount, + ) + ) + ) + signers = [payer, source_transfer_authority, destination_stake] \ + if payer != source_transfer_authority else [payer, destination_stake] + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, *signers, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def update_stake_pool(client: AsyncClient, payer: Keypair, stake_pool_address: Pubkey): + """Create and send all instructions to completely update a stake pool after epoch change.""" + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + (withdraw_authority, seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + update_list_instructions = [] + validator_chunks = [ + validator_list.validators[i:i+MAX_VALIDATORS_TO_UPDATE] + for i in range(0, len(validator_list.validators), MAX_VALIDATORS_TO_UPDATE) + ] + start_index = 0 + for validator_chunk in validator_chunks: + validator_and_transient_stake_pairs = [] + for validator in validator_chunk: + (validator_stake_address, _) = find_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator.vote_account_address, + stake_pool_address, + validator.validator_seed_suffix or None, + ) + validator_and_transient_stake_pairs.append(validator_stake_address) + (transient_stake_address, _) = find_transient_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator.vote_account_address, + stake_pool_address, + validator.transient_seed_suffix, + ) + validator_and_transient_stake_pairs.append(transient_stake_address) + update_list_instructions.append( + sp.update_validator_list_balance( + sp.UpdateValidatorListBalanceParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + stake_program_id=STAKE_PROGRAM_ID, + validator_and_transient_stake_pairs=validator_and_transient_stake_pairs, + start_index=start_index, + no_merge=False, + ) + ) + ) + start_index += MAX_VALIDATORS_TO_UPDATE + if update_list_instructions: + last_instruction = update_list_instructions.pop() + for update_list_instruction in update_list_instructions: + txn = Transaction(fee_payer=payer.pubkey()) + txn.add(update_list_instruction) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, + opts=TxOpts(skip_confirmation=True, preflight_commitment=Confirmed)) + txn = Transaction(fee_payer=payer.pubkey()) + txn.add(last_instruction) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, opts=OPTS) + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sp.update_stake_pool_balance( + sp.UpdateStakePoolBalanceParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + manager_fee_account=stake_pool.manager_fee_account, + pool_mint=stake_pool.pool_mint, + token_program_id=stake_pool.token_program_id, + ) + ) + ) + txn.add( + sp.cleanup_removed_validator_entries( + sp.CleanupRemovedValidatorEntriesParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + validator_list=stake_pool.validator_list, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def increase_validator_stake( + client: AsyncClient, + payer: Keypair, + staker: Keypair, + stake_pool_address: Pubkey, + validator_vote: Pubkey, + lamports: int, + ephemeral_stake_seed: Optional[int] = None +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + (withdraw_authority, seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + + validator_info = next(x for x in validator_list.validators if x.vote_account_address == validator_vote) + + if ephemeral_stake_seed is None: + transient_stake_seed = validator_info.transient_seed_suffix + 1 # bump up by one to avoid reuse + else: + # we are updating an existing transient stake account, so we must use the same seed + transient_stake_seed = validator_info.transient_seed_suffix + + validator_stake_seed = validator_info.validator_seed_suffix or None + (transient_stake, _) = find_transient_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_info.vote_account_address, + stake_pool_address, + transient_stake_seed, + ) + (validator_stake, _) = find_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_info.vote_account_address, + stake_pool_address, + validator_stake_seed + ) + + txn = Transaction(fee_payer=payer.pubkey()) + if ephemeral_stake_seed is not None: + + # We assume there is an existing transient account that we will update + (ephemeral_stake, _) = find_ephemeral_stake_program_address( + STAKE_POOL_PROGRAM_ID, + stake_pool_address, + ephemeral_stake_seed) + + txn.add( + sp.increase_additional_validator_stake( + sp.IncreaseAdditionalValidatorStakeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + staker=staker.pubkey(), + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + transient_stake=transient_stake, + validator_stake=validator_stake, + validator_vote=validator_vote, + clock_sysvar=CLOCK, + rent_sysvar=RENT, + stake_history_sysvar=STAKE_HISTORY, + stake_config_sysvar=SYSVAR_STAKE_CONFIG_ID, + system_program_id=sys.ID, + stake_program_id=STAKE_PROGRAM_ID, + lamports=lamports, + transient_stake_seed=transient_stake_seed, + ephemeral_stake=ephemeral_stake, + ephemeral_stake_seed=ephemeral_stake_seed + ) + ) + ) + + else: + txn.add( + sp.increase_validator_stake( + sp.IncreaseValidatorStakeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + staker=staker.pubkey(), + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + transient_stake=transient_stake, + validator_stake=validator_stake, + validator_vote=validator_vote, + clock_sysvar=CLOCK, + rent_sysvar=RENT, + stake_history_sysvar=STAKE_HISTORY, + stake_config_sysvar=SYSVAR_STAKE_CONFIG_ID, + system_program_id=sys.ID, + stake_program_id=STAKE_PROGRAM_ID, + lamports=lamports, + transient_stake_seed=transient_stake_seed, + ) + ) + ) + + signers = [payer, staker] if payer != staker else [payer] + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, *signers, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def decrease_validator_stake( + client: AsyncClient, + payer: Keypair, + staker: Keypair, + stake_pool_address: Pubkey, + validator_vote: Pubkey, + lamports: int, + ephemeral_stake_seed: Optional[int] = None +): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + resp = await client.get_account_info(stake_pool.validator_list, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + (withdraw_authority, seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + + validator_info = next(x for x in validator_list.validators if x.vote_account_address == validator_vote) + validator_stake_seed = validator_info.validator_seed_suffix or None + (validator_stake, _) = find_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_info.vote_account_address, + stake_pool_address, + validator_stake_seed, + ) + + if ephemeral_stake_seed is None: + transient_stake_seed = validator_info.transient_seed_suffix + 1 # bump up by one to avoid reuse + else: + # we are updating an existing transient stake account, so we must use the same seed + transient_stake_seed = validator_info.transient_seed_suffix + + (transient_stake, _) = find_transient_stake_program_address( + STAKE_POOL_PROGRAM_ID, + validator_info.vote_account_address, + stake_pool_address, + transient_stake_seed, + ) + + txn = Transaction(fee_payer=payer.pubkey()) + + if ephemeral_stake_seed is not None: + + # We assume there is an existing transient account that we will update + (ephemeral_stake, _) = find_ephemeral_stake_program_address( + STAKE_POOL_PROGRAM_ID, + stake_pool_address, + ephemeral_stake_seed) + + txn.add( + sp.decrease_additional_validator_stake( + sp.DecreaseAdditionalValidatorStakeParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + staker=staker.pubkey(), + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + validator_stake=validator_stake, + transient_stake=transient_stake, + clock_sysvar=CLOCK, + rent_sysvar=RENT, + stake_history_sysvar=STAKE_HISTORY, + system_program_id=sys.ID, + stake_program_id=STAKE_PROGRAM_ID, + lamports=lamports, + transient_stake_seed=transient_stake_seed, + ephemeral_stake=ephemeral_stake, + ephemeral_stake_seed=ephemeral_stake_seed + ) + ) + ) + + else: + + txn.add( + sp.decrease_validator_stake_with_reserve( + sp.DecreaseValidatorStakeWithReserveParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + staker=staker.pubkey(), + withdraw_authority=withdraw_authority, + validator_list=stake_pool.validator_list, + reserve_stake=stake_pool.reserve_stake, + validator_stake=validator_stake, + transient_stake=transient_stake, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + system_program_id=sys.ID, + stake_program_id=STAKE_PROGRAM_ID, + lamports=lamports, + transient_stake_seed=transient_stake_seed, + ) + ) + ) + + signers = [payer, staker] if payer != staker else [payer] + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, *signers, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def create_token_metadata(client: AsyncClient, payer: Keypair, stake_pool_address: Pubkey, + name: str, symbol: str, uri: str): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + (withdraw_authority, _seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + (token_metadata, _seed) = find_metadata_account(stake_pool.pool_mint) + + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sp.create_token_metadata( + sp.CreateTokenMetadataParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + manager=stake_pool.manager, + pool_mint=stake_pool.pool_mint, + payer=payer.pubkey(), + name=name, + symbol=symbol, + uri=uri, + withdraw_authority=withdraw_authority, + token_metadata=token_metadata, + metadata_program_id=METADATA_PROGRAM_ID, + system_program_id=sys.ID, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, opts=OPTS) + + +async def update_token_metadata(client: AsyncClient, payer: Keypair, stake_pool_address: Pubkey, + name: str, symbol: str, uri: str): + resp = await client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + (withdraw_authority, _seed) = find_withdraw_authority_program_address(STAKE_POOL_PROGRAM_ID, stake_pool_address) + (token_metadata, _seed) = find_metadata_account(stake_pool.pool_mint) + + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sp.update_token_metadata( + sp.UpdateTokenMetadataParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool_address, + manager=stake_pool.manager, + pool_mint=stake_pool.pool_mint, + name=name, + symbol=symbol, + uri=uri, + withdraw_authority=withdraw_authority, + token_metadata=token_metadata, + metadata_program_id=METADATA_PROGRAM_ID, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction(txn, payer, recent_blockhash=recent_blockhash, opts=OPTS) diff --git a/clients/py/stake_pool/constants.py b/clients/py/stake_pool/constants.py new file mode 100644 index 00000000..8a09dfbb --- /dev/null +++ b/clients/py/stake_pool/constants.py @@ -0,0 +1,121 @@ +"""SPL Stake Pool Constants.""" + +from typing import Optional, Tuple + +from solders.pubkey import Pubkey +from stake.constants import MINIMUM_DELEGATION + +STAKE_POOL_PROGRAM_ID = Pubkey.from_string("SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy") +"""Public key that identifies the SPL Stake Pool program.""" + +MAX_VALIDATORS_TO_UPDATE: int = 5 +"""Maximum number of validators to update during UpdateValidatorListBalance.""" + +MINIMUM_RESERVE_LAMPORTS: int = 0 +"""Minimum balance required in the stake pool reserve""" + +MINIMUM_ACTIVE_STAKE: int = MINIMUM_DELEGATION +"""Minimum active delegated staked required in a stake account""" + +METADATA_PROGRAM_ID = Pubkey.from_string("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s") +"""Public key that identifies the Metaplex Token Metadata program.""" + + +def find_deposit_authority_program_address( + program_id: Pubkey, + stake_pool_address: Pubkey, +) -> Tuple[Pubkey, int]: + """Generates the deposit authority program address for the stake pool""" + return Pubkey.find_program_address( + [bytes(stake_pool_address), AUTHORITY_DEPOSIT], + program_id, + ) + + +def find_withdraw_authority_program_address( + program_id: Pubkey, + stake_pool_address: Pubkey, +) -> Tuple[Pubkey, int]: + """Generates the withdraw authority program address for the stake pool""" + return Pubkey.find_program_address( + [bytes(stake_pool_address), AUTHORITY_WITHDRAW], + program_id, + ) + + +def find_stake_program_address( + program_id: Pubkey, + vote_account_address: Pubkey, + stake_pool_address: Pubkey, + seed: Optional[int] +) -> Tuple[Pubkey, int]: + """Generates the stake program address for a validator's vote account""" + return Pubkey.find_program_address( + [ + bytes(vote_account_address), + bytes(stake_pool_address), + seed.to_bytes(4, 'little') if seed else bytes(), + ], + program_id, + ) + + +def find_transient_stake_program_address( + program_id: Pubkey, + vote_account_address: Pubkey, + stake_pool_address: Pubkey, + seed: int, +) -> Tuple[Pubkey, int]: + """Generates the stake program address for a validator's vote account""" + return Pubkey.find_program_address( + [ + TRANSIENT_STAKE_SEED_PREFIX, + bytes(vote_account_address), + bytes(stake_pool_address), + seed.to_bytes(8, 'little'), + ], + program_id, + ) + + +def find_ephemeral_stake_program_address( + program_id: Pubkey, + stake_pool_address: Pubkey, + seed: int +) -> Tuple[Pubkey, int]: + + """Generates the ephemeral program address for stake pool redelegation""" + return Pubkey.find_program_address( + [ + EPHEMERAL_STAKE_SEED_PREFIX, + bytes(stake_pool_address), + seed.to_bytes(8, 'little'), + ], + program_id, + ) + + +def find_metadata_account( + mint_key: Pubkey +) -> Tuple[Pubkey, int]: + """Generates the metadata account program address""" + return Pubkey.find_program_address( + [ + METADATA_SEED_PREFIX, + bytes(METADATA_PROGRAM_ID), + bytes(mint_key) + ], + METADATA_PROGRAM_ID + ) + + +AUTHORITY_DEPOSIT = b"deposit" +"""Seed used to derive the default stake pool deposit authority.""" +AUTHORITY_WITHDRAW = b"withdraw" +"""Seed used to derive the stake pool withdraw authority.""" +TRANSIENT_STAKE_SEED_PREFIX = b"transient" +"""Seed used to derive transient stake accounts.""" +METADATA_SEED_PREFIX = b"metadata" +"""Seed used to avoid certain collision attacks.""" +EPHEMERAL_STAKE_SEED_PREFIX = b'ephemeral' +"""Seed for ephemeral stake account""" diff --git a/clients/py/stake_pool/instructions.py b/clients/py/stake_pool/instructions.py new file mode 100644 index 00000000..493d2251 --- /dev/null +++ b/clients/py/stake_pool/instructions.py @@ -0,0 +1,1277 @@ +"""SPL Stake Pool Instructions.""" + +from enum import IntEnum +from typing import List, NamedTuple, Optional +from construct import Prefixed, GreedyString, Struct, Switch, Int8ul, Int32ul, Int64ul, Pass # type: ignore + +from solana.constants import SYSTEM_PROGRAM_ID +from solders.pubkey import Pubkey +from solders.instruction import AccountMeta, Instruction +from solders.sysvar import CLOCK, RENT, STAKE_HISTORY +from spl.token.constants import TOKEN_PROGRAM_ID + +from stake.constants import STAKE_PROGRAM_ID, SYSVAR_STAKE_CONFIG_ID +from stake_pool.constants import find_stake_program_address, find_transient_stake_program_address +from stake_pool.constants import find_withdraw_authority_program_address +from stake_pool.constants import STAKE_POOL_PROGRAM_ID +from stake_pool.state import Fee, FEE_LAYOUT + + +class PreferredValidatorType(IntEnum): + """Specifies the validator type for SetPreferredValidator instruction.""" + + DEPOSIT = 0 + """Specifies the preferred deposit validator.""" + WITHDRAW = 1 + """Specifies the preferred withdraw validator.""" + + +class FundingType(IntEnum): + """Defines which authority to update in the `SetFundingAuthority` instruction.""" + + STAKE_DEPOSIT = 0 + """Sets the stake deposit authority.""" + SOL_DEPOSIT = 1 + """Sets the SOL deposit authority.""" + SOL_WITHDRAW = 2 + """Sets the SOL withdraw authority.""" + + +class InitializeParams(NamedTuple): + """Initialize token mint transaction params.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """[w] Stake Pool account to initialize.""" + manager: Pubkey + """[s] Manager for new stake pool.""" + staker: Pubkey + """[] Staker for the new stake pool.""" + withdraw_authority: Pubkey + """[] Withdraw authority for the new stake pool.""" + validator_list: Pubkey + """[w] Uninitialized validator list account for the new stake pool.""" + reserve_stake: Pubkey + """[] Reserve stake account.""" + pool_mint: Pubkey + """[w] Pool token mint account.""" + manager_fee_account: Pubkey + """[w] Manager's fee account""" + token_program_id: Pubkey + """[] SPL Token program id.""" + + # Params + epoch_fee: Fee + """Fee assessed as percentage of rewards.""" + withdrawal_fee: Fee + """Fee charged per withdrawal.""" + deposit_fee: Fee + """Fee charged per deposit.""" + referral_fee: int + """Percentage [0-100] of deposit fee that goes to referrer.""" + max_validators: int + """Maximum number of possible validators in the pool.""" + + # Optional + deposit_authority: Optional[Pubkey] = None + """[] Optional deposit authority that must sign all deposits.""" + + +class AddValidatorToPoolParams(NamedTuple): + """(Staker only) Adds stake account delegated to validator to the pool's list of managed validators.""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + reserve_stake: Pubkey + """`[w]` Reserve stake account.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + validator_stake: Pubkey + """`[w]` Stake account to add to the pool.""" + validator_vote: Pubkey + """`[]` Validator this stake account will be delegated to.""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + stake_config_sysvar: Pubkey + """'[]' Stake config sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + seed: Optional[int] + """Seed to used to create the validator stake account.""" + + +class RemoveValidatorFromPoolParams(NamedTuple): + """(Staker only) Removes validator from the pool.""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + validator_stake: Pubkey + """`[w]` Stake account to remove from the pool.""" + transient_stake: Pubkey + """`[]` Transient stake account, to check that there's no activation ongoing.""" + clock_sysvar: Pubkey + """'[]' Stake config sysvar.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + +class DecreaseValidatorStakeParams(NamedTuple): + """(Staker only) Decrease active stake on a validator, eventually moving it to the reserve""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + validator_stake: Pubkey + """`[w]` Canonical stake to split from.""" + transient_stake: Pubkey + """`[w]` Transient stake account to receive split.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + lamports: int + """Amount of lamports to split into the transient stake account.""" + transient_stake_seed: int + """Seed to used to create the transient stake account.""" + + +class DecreaseValidatorStakeWithReserveParams(NamedTuple): + """(Staker only) Decrease active stake on a validator, eventually moving it to the reserve""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + validator_stake: Pubkey + """`[w]` Canonical stake to split from.""" + transient_stake: Pubkey + """`[w]` Transient stake account to receive split.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + lamports: int + """Amount of lamports to split into the transient stake account.""" + transient_stake_seed: int + """Seed to used to create the transient stake account.""" + + +class IncreaseValidatorStakeParams(NamedTuple): + """(Staker only) Increase stake on a validator from the reserve account.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + transient_stake: Pubkey + """`[w]` Transient stake account to receive split.""" + validator_stake: Pubkey + """`[]` Canonical stake account to check.""" + validator_vote: Pubkey + """`[]` Validator vote account to delegate to.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + stake_config_sysvar: Pubkey + """'[]' Stake config sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + lamports: int + """Amount of lamports to split into the transient stake account.""" + transient_stake_seed: int + """Seed to used to create the transient stake account.""" + + +class SetPreferredValidatorParams(NamedTuple): + pass + + +class UpdateValidatorListBalanceParams(NamedTuple): + """Updates balances of validator and transient stake accounts in the pool.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + validator_and_transient_stake_pairs: List[Pubkey] + """[] N pairs of validator and transient stake accounts""" + + # Params + start_index: int + """Index to start updating on the validator list.""" + no_merge: bool + """If true, don't try merging transient stake accounts.""" + + +class UpdateStakePoolBalanceParams(NamedTuple): + """Updates total pool balance based on balances in the reserve and validator list.""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + manager_fee_account: Pubkey + """`[w]` Account to receive pool fee tokens.""" + pool_mint: Pubkey + """`[w]` Pool mint account.""" + token_program_id: Pubkey + """`[]` Pool token program.""" + + +class CleanupRemovedValidatorEntriesParams(NamedTuple): + """Cleans up validator stake account entries marked as `ReadyForRemoval`""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + + +class DepositStakeParams(NamedTuple): + """Deposits a stake account into the pool in exchange for pool tokens""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool""" + validator_list: Pubkey + """`[w]` Validator stake list storage account""" + deposit_authority: Pubkey + """`[s]/[]` Stake pool deposit authority""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority""" + deposit_stake: Pubkey + """`[w]` Stake account to join the pool (stake's withdraw authority set to the stake pool deposit authority)""" + validator_stake: Pubkey + """`[w]` Validator stake account for the stake account to be merged with""" + reserve_stake: Pubkey + """`[w]` Reserve stake account, to withdraw rent exempt reserve""" + destination_pool_account: Pubkey + """`[w]` User account to receive pool tokens""" + manager_fee_account: Pubkey + """`[w]` Account to receive pool fee tokens""" + referral_pool_account: Pubkey + """`[w]` Account to receive a portion of pool fee tokens as referral fees""" + pool_mint: Pubkey + """`[w]` Pool token mint account""" + clock_sysvar: Pubkey + """`[]` Sysvar clock account""" + stake_history_sysvar: Pubkey + """`[]` Sysvar stake history account""" + token_program_id: Pubkey + """`[]` Pool token program id""" + stake_program_id: Pubkey + """`[]` Stake program id""" + + +class WithdrawStakeParams(NamedTuple): + """Withdraws a stake account from the pool in exchange for pool tokens""" + + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool""" + validator_list: Pubkey + """`[w]` Validator stake list storage account""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority""" + validator_stake: Pubkey + """`[w]` Validator or reserve stake account to split""" + destination_stake: Pubkey + """`[w]` Uninitialized stake account to receive withdrawal""" + destination_stake_authority: Pubkey + """`[]` User account to set as a new withdraw authority""" + source_transfer_authority: Pubkey + """`[s]` User transfer authority, for pool token account""" + source_pool_account: Pubkey + """`[w]` User account with pool tokens to burn from""" + manager_fee_account: Pubkey + """`[w]` Account to receive pool fee tokens""" + pool_mint: Pubkey + """`[w]` Pool token mint account""" + clock_sysvar: Pubkey + """`[]` Sysvar clock account""" + token_program_id: Pubkey + """`[]` Pool token program id""" + stake_program_id: Pubkey + """`[]` Stake program id""" + + # Params + amount: int + """Amount of pool tokens to burn in exchange for stake""" + + +class SetManagerParams(NamedTuple): + pass + + +class SetFeeParams(NamedTuple): + pass + + +class SetStakerParams(NamedTuple): + pass + + +class DepositSolParams(NamedTuple): + """Deposit SOL directly into the pool's reserve account. The output is a "pool" token + representing ownership into the pool. Inputs are converted to the current ratio.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + funding_account: Pubkey + """`[ws]` Funding account (must be a system account).""" + destination_pool_account: Pubkey + """`[w]` User account to receive pool tokens.""" + manager_fee_account: Pubkey + """`[w]` Manager's pool token account to receive deposit fee.""" + referral_pool_account: Pubkey + """`[w]` Referrer pool token account to receive referral fee.""" + pool_mint: Pubkey + """`[w]` Pool token mint.""" + system_program_id: Pubkey + """`[]` System program.""" + token_program_id: Pubkey + """`[]` Token program.""" + + # Params + amount: int + """Amount of SOL to deposit""" + + # Optional + deposit_authority: Optional[Pubkey] = None + """`[s]` (Optional) Stake pool sol deposit authority.""" + + +class SetFundingAuthorityParams(NamedTuple): + pass + + +class WithdrawSolParams(NamedTuple): + """Withdraw SOL directly from the pool's reserve account.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[w]` Stake pool.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + source_transfer_authority: Pubkey + """`[s]` Transfer authority for user pool token account.""" + source_pool_account: Pubkey + """`[w]` User's pool token account to burn pool tokens.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + destination_system_account: Pubkey + """`[w]` Destination system account to receive lamports from the reserve.""" + manager_fee_account: Pubkey + """`[w]` Manager's pool token account to receive fee.""" + pool_mint: Pubkey + """`[w]` Pool token mint.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + token_program_id: Pubkey + """`[]` Token program.""" + + # Params + amount: int + """Amount of pool tokens to burn""" + + # Optional + sol_withdraw_authority: Optional[Pubkey] = None + """`[s]` (Optional) Stake pool sol withdraw authority.""" + + +class CreateTokenMetadataParams(NamedTuple): + """Create token metadata for the stake-pool token in the metaplex-token program.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + manager: Pubkey + """`[s]` Manager.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + pool_mint: Pubkey + """`[]` Pool token mint account.""" + payer: Pubkey + """`[s, w]` Payer for creation of token metadata account.""" + token_metadata: Pubkey + """`[w]` Token metadata program account.""" + metadata_program_id: Pubkey + """`[]` Metadata program id""" + system_program_id: Pubkey + """`[]` System program id""" + + # Params + name: str + """Token name.""" + symbol: str + """Token symbol e.g. stkSOL.""" + uri: str + """URI of the uploaded metadata of the spl-token.""" + + +class UpdateTokenMetadataParams(NamedTuple): + """Update token metadata for the stake-pool token in the metaplex-token program.""" + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + manager: Pubkey + """`[s]` Manager.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + pool_mint: Pubkey + """`[]` Pool token mint account.""" + token_metadata: Pubkey + """`[w]` Token metadata program account.""" + metadata_program_id: Pubkey + """`[]` Metadata program id""" + + # Params + name: str + """Token name.""" + symbol: str + """Token symbol e.g. stkSOL.""" + uri: str + """URI of the uploaded metadata of the spl-token.""" + + +class IncreaseAdditionalValidatorStakeParams(NamedTuple): + """(Staker only) Increase stake on a validator from the reserve account.""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """`[w]` Stake pool's reserve.""" + ephemeral_stake: Pubkey + """The ephemeral stake account used during the operation.""" + transient_stake: Pubkey + """`[w]` Transient stake account to receive split.""" + validator_stake: Pubkey + """`[]` Canonical stake account to check.""" + validator_vote: Pubkey + """`[]` Validator vote account to delegate to.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + stake_config_sysvar: Pubkey + """'[]' Stake config sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + lamports: int + """Amount of lamports to increase on the given validator.""" + transient_stake_seed: int + """Seed to used to create the transient stake account.""" + ephemeral_stake_seed: int + """The seed used to generate the ephemeral stake account""" + + +class DecreaseAdditionalValidatorStakeParams(NamedTuple): + """(Staker only) Decrease active stake on a validator, eventually moving it to the reserve""" + + # Accounts + program_id: Pubkey + """SPL Stake Pool program account.""" + stake_pool: Pubkey + """`[]` Stake pool.""" + staker: Pubkey + """`[s]` Staker.""" + withdraw_authority: Pubkey + """`[]` Stake pool withdraw authority.""" + validator_list: Pubkey + """`[w]` Validator stake list storage account.""" + reserve_stake: Pubkey + """The reserve stake account to move the stake to.""" + validator_stake: Pubkey + """`[w]` Canonical stake to split from.""" + ephemeral_stake: Pubkey + """The ephemeral stake account used during the operation.""" + transient_stake: Pubkey + """`[w]` Transient stake account to receive split.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + stake_history_sysvar: Pubkey + """'[]' Stake history sysvar.""" + system_program_id: Pubkey + """`[]` System program.""" + stake_program_id: Pubkey + """`[]` Stake program.""" + + # Params + lamports: int + """Amount of lamports to split into the transient stake account.""" + transient_stake_seed: int + """Seed to used to create the transient stake account.""" + ephemeral_stake_seed: int + """The seed used to generate the ephemeral stake account""" + + +class InstructionType(IntEnum): + """Stake Pool Instruction Types.""" + + INITIALIZE = 0 + ADD_VALIDATOR_TO_POOL = 1 + REMOVE_VALIDATOR_FROM_POOL = 2 + DECREASE_VALIDATOR_STAKE = 3 + INCREASE_VALIDATOR_STAKE = 4 + SET_PREFERRED_VALIDATOR = 5 + UPDATE_VALIDATOR_LIST_BALANCE = 6 + UPDATE_STAKE_POOL_BALANCE = 7 + CLEANUP_REMOVED_VALIDATOR_ENTRIES = 8 + DEPOSIT_STAKE = 9 + WITHDRAW_STAKE = 10 + SET_MANAGER = 11 + SET_FEE = 12 + SET_STAKER = 13 + DEPOSIT_SOL = 14 + SET_FUNDING_AUTHORITY = 15 + WITHDRAW_SOL = 16 + CREATE_TOKEN_METADATA = 17 + UPDATE_TOKEN_METADATA = 18 + INCREASE_ADDITIONAL_VALIDATOR_STAKE = 19 + DECREASE_ADDITIONAL_VALIDATOR_STAKE = 20 + DECREASE_VALIDATOR_STAKE_WITH_RESERVE = 21 + REDELEGATE = 22 + + +INITIALIZE_LAYOUT = Struct( + "epoch_fee" / FEE_LAYOUT, + "withdrawal_fee" / FEE_LAYOUT, + "deposit_fee" / FEE_LAYOUT, + "referral_fee" / Int8ul, + "max_validators" / Int32ul, +) + +MOVE_STAKE_LAYOUT = Struct( + "lamports" / Int64ul, + "transient_stake_seed" / Int64ul, +) + +MOVE_STAKE_LAYOUT_WITH_EPHEMERAL_STAKE = Struct( + "lamports" / Int64ul, + "transient_stake_seed" / Int64ul, + "ephemeral_stake_seed" / Int64ul, +) + +UPDATE_VALIDATOR_LIST_BALANCE_LAYOUT = Struct( + "start_index" / Int32ul, + "no_merge" / Int8ul, +) + +AMOUNT_LAYOUT = Struct( + "amount" / Int64ul +) + +SEED_LAYOUT = Struct( + "seed" / Int32ul +) + +TOKEN_METADATA_LAYOUT = Struct( + "name" / Prefixed(Int32ul, GreedyString("utf8")), + "symbol" / Prefixed(Int32ul, GreedyString("utf8")), + "uri" / Prefixed(Int32ul, GreedyString("utf8")) +) + +INSTRUCTIONS_LAYOUT = Struct( + "instruction_type" / Int8ul, + "args" + / Switch( + lambda this: this.instruction_type, + { + InstructionType.INITIALIZE: INITIALIZE_LAYOUT, + InstructionType.ADD_VALIDATOR_TO_POOL: SEED_LAYOUT, + InstructionType.REMOVE_VALIDATOR_FROM_POOL: Pass, + InstructionType.DECREASE_VALIDATOR_STAKE: MOVE_STAKE_LAYOUT, + InstructionType.INCREASE_VALIDATOR_STAKE: MOVE_STAKE_LAYOUT, + InstructionType.SET_PREFERRED_VALIDATOR: Pass, # TODO + InstructionType.UPDATE_VALIDATOR_LIST_BALANCE: UPDATE_VALIDATOR_LIST_BALANCE_LAYOUT, + InstructionType.UPDATE_STAKE_POOL_BALANCE: Pass, + InstructionType.CLEANUP_REMOVED_VALIDATOR_ENTRIES: Pass, + InstructionType.DEPOSIT_STAKE: Pass, + InstructionType.WITHDRAW_STAKE: AMOUNT_LAYOUT, + InstructionType.SET_MANAGER: Pass, # TODO + InstructionType.SET_FEE: Pass, # TODO + InstructionType.SET_STAKER: Pass, # TODO + InstructionType.DEPOSIT_SOL: AMOUNT_LAYOUT, + InstructionType.SET_FUNDING_AUTHORITY: Pass, # TODO + InstructionType.WITHDRAW_SOL: AMOUNT_LAYOUT, + InstructionType.CREATE_TOKEN_METADATA: TOKEN_METADATA_LAYOUT, + InstructionType.UPDATE_TOKEN_METADATA: TOKEN_METADATA_LAYOUT, + InstructionType.DECREASE_ADDITIONAL_VALIDATOR_STAKE: MOVE_STAKE_LAYOUT_WITH_EPHEMERAL_STAKE, + InstructionType.INCREASE_ADDITIONAL_VALIDATOR_STAKE: MOVE_STAKE_LAYOUT_WITH_EPHEMERAL_STAKE, + InstructionType.DECREASE_VALIDATOR_STAKE_WITH_RESERVE: MOVE_STAKE_LAYOUT, + }, + ), +) + + +def initialize(params: InitializeParams) -> Instruction: + """Creates a transaction instruction to initialize a new stake pool.""" + + data = INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.INITIALIZE, + args=dict( + epoch_fee=params.epoch_fee._asdict(), + withdrawal_fee=params.withdrawal_fee._asdict(), + deposit_fee=params.deposit_fee._asdict(), + referral_fee=params.referral_fee, + max_validators=params.max_validators + ), + ) + ) + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=TOKEN_PROGRAM_ID, is_signer=False, is_writable=False), + ] + if params.deposit_authority: + accounts.append( + AccountMeta(pubkey=params.deposit_authority, is_signer=True, is_writable=False), + ) + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=data, + ) + + +def add_validator_to_pool(params: AddValidatorToPoolParams) -> Instruction: + """Creates instruction to add a validator to the pool.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_vote, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.rent_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_config_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.ADD_VALIDATOR_TO_POOL, + args={'seed': params.seed or 0} + ) + ) + ) + + +def add_validator_to_pool_with_vote( + program_id: Pubkey, + stake_pool: Pubkey, + staker: Pubkey, + validator_list: Pubkey, + reserve_stake: Pubkey, + validator: Pubkey, + validator_stake_seed: Optional[int], +) -> Instruction: + """Creates instruction to add a validator based on their vote account address.""" + (withdraw_authority, _seed) = find_withdraw_authority_program_address(program_id, stake_pool) + (validator_stake, _seed) = find_stake_program_address(program_id, validator, stake_pool, validator_stake_seed) + return add_validator_to_pool( + AddValidatorToPoolParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool, + staker=staker, + reserve_stake=reserve_stake, + withdraw_authority=withdraw_authority, + validator_list=validator_list, + validator_stake=validator_stake, + validator_vote=validator, + rent_sysvar=RENT, + clock_sysvar=CLOCK, + stake_history_sysvar=STAKE_HISTORY, + stake_config_sysvar=SYSVAR_STAKE_CONFIG_ID, + system_program_id=SYSTEM_PROGRAM_ID, + stake_program_id=STAKE_PROGRAM_ID, + seed=validator_stake_seed, + ) + ) + + +def remove_validator_from_pool(params: RemoveValidatorFromPoolParams) -> Instruction: + """Creates instruction to remove a validator from the pool.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.REMOVE_VALIDATOR_FROM_POOL, + args=None + ) + ) + ) + + +def remove_validator_from_pool_with_vote( + program_id: Pubkey, + stake_pool: Pubkey, + staker: Pubkey, + validator_list: Pubkey, + validator: Pubkey, + validator_stake_seed: Optional[int], + transient_stake_seed: int, +) -> Instruction: + """Creates instruction to remove a validator based on their vote account address.""" + (withdraw_authority, seed) = find_withdraw_authority_program_address(program_id, stake_pool) + (validator_stake, seed) = find_stake_program_address(program_id, validator, stake_pool, validator_stake_seed) + (transient_stake, seed) = find_transient_stake_program_address( + program_id, validator, stake_pool, transient_stake_seed) + return remove_validator_from_pool( + RemoveValidatorFromPoolParams( + program_id=STAKE_POOL_PROGRAM_ID, + stake_pool=stake_pool, + staker=staker, + withdraw_authority=withdraw_authority, + validator_list=validator_list, + validator_stake=validator_stake, + transient_stake=transient_stake, + clock_sysvar=CLOCK, + stake_program_id=STAKE_PROGRAM_ID, + ) + ) + + +def deposit_stake(params: DepositStakeParams) -> Instruction: + """Creates a transaction instruction to deposit a stake account into a stake pool.""" + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.deposit_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.deposit_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.destination_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.referral_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.token_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ] + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DEPOSIT_STAKE, + args=None, + ) + ) + ) + + +def withdraw_stake(params: WithdrawStakeParams) -> Instruction: + """Creates a transaction instruction to withdraw active stake from a stake pool.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.destination_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.destination_stake_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.source_transfer_authority, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.source_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.token_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.WITHDRAW_STAKE, + args={'amount': params.amount} + ) + ) + ) + + +def deposit_sol(params: DepositSolParams) -> Instruction: + """Creates a transaction instruction to deposit SOL into a stake pool.""" + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.funding_account, is_signer=True, is_writable=True), + AccountMeta(pubkey=params.destination_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.referral_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.token_program_id, is_signer=False, is_writable=False), + ] + if params.deposit_authority: + accounts.append(AccountMeta(pubkey=params.deposit_authority, is_signer=True, is_writable=False)) + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DEPOSIT_SOL, + args={'amount': params.amount} + ) + ) + ) + + +def withdraw_sol(params: WithdrawSolParams) -> Instruction: + """Creates a transaction instruction to withdraw SOL from a stake pool.""" + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.source_transfer_authority, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.source_pool_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.destination_system_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.token_program_id, is_signer=False, is_writable=False), + ] + + if params.sol_withdraw_authority: + AccountMeta(pubkey=params.sol_withdraw_authority, is_signer=True, is_writable=False) + + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.WITHDRAW_SOL, + args={'amount': params.amount} + ) + ) + ) + + +def update_validator_list_balance(params: UpdateValidatorListBalanceParams) -> Instruction: + """Creates instruction to update a set of validators in the stake pool.""" + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ] + accounts.extend([ + AccountMeta(pubkey=pubkey, is_signer=False, is_writable=True) + for pubkey in params.validator_and_transient_stake_pairs + ]) + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.UPDATE_VALIDATOR_LIST_BALANCE, + args={'start_index': params.start_index, 'no_merge': params.no_merge} + ) + ) + ) + + +def update_stake_pool_balance(params: UpdateStakePoolBalanceParams) -> Instruction: + """Creates instruction to update the overall stake pool balance.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.manager_fee_account, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.token_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.UPDATE_STAKE_POOL_BALANCE, + args=None, + ) + ) + ) + + +def cleanup_removed_validator_entries(params: CleanupRemovedValidatorEntriesParams) -> Instruction: + """Creates instruction to cleanup removed validator entries.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.CLEANUP_REMOVED_VALIDATOR_ENTRIES, + args=None, + ) + ) + ) + + +def increase_validator_stake(params: IncreaseValidatorStakeParams) -> Instruction: + """Creates instruction to increase the stake on a validator.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_vote, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.rent_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_config_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.INCREASE_VALIDATOR_STAKE, + args={ + 'lamports': params.lamports, + 'transient_stake_seed': params.transient_stake_seed + } + ) + ) + ) + + +def increase_additional_validator_stake( + params: IncreaseAdditionalValidatorStakeParams, + ) -> Instruction: + + """Creates `IncreaseAdditionalValidatorStake` instruction (rebalance from reserve account to transient account)""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.ephemeral_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_vote, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_config_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.INCREASE_ADDITIONAL_VALIDATOR_STAKE, + args={ + 'lamports': params.lamports, + 'transient_stake_seed': params.transient_stake_seed, + 'ephemeral_stake_seed': params.ephemeral_stake_seed + } + ) + ) + ) + + +def decrease_validator_stake(params: DecreaseValidatorStakeParams) -> Instruction: + """Creates instruction to decrease the stake on a validator.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.rent_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DECREASE_VALIDATOR_STAKE, + args={ + 'lamports': params.lamports, + 'transient_stake_seed': params.transient_stake_seed + } + ) + ) + ) + + +def decrease_additional_validator_stake(params: DecreaseAdditionalValidatorStakeParams) -> Instruction: + """ Creates `DecreaseAdditionalValidatorStake` instruction (rebalance from validator account to + transient account).""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.ephemeral_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DECREASE_ADDITIONAL_VALIDATOR_STAKE, + args={ + 'lamports': params.lamports, + 'transient_stake_seed': params.transient_stake_seed, + 'ephemeral_stake_seed': params.ephemeral_stake_seed + } + ) + ) + ) + + +def decrease_validator_stake_with_reserve(params: DecreaseValidatorStakeWithReserveParams) -> Instruction: + """Creates instruction to decrease the stake on a validator.""" + return Instruction( + accounts=[ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.staker, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.validator_list, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.reserve_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.validator_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.transient_stake, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.clock_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_history_sysvar, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.stake_program_id, is_signer=False, is_writable=False), + ], + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.DECREASE_VALIDATOR_STAKE_WITH_RESERVE, + args={ + 'lamports': params.lamports, + 'transient_stake_seed': params.transient_stake_seed + } + ) + ) + ) + + +def create_token_metadata(params: CreateTokenMetadataParams) -> Instruction: + """Creates an instruction to create metadata using the mpl token metadata program for the pool token.""" + + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.manager, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.pool_mint, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.payer, is_signer=True, is_writable=True), + AccountMeta(pubkey=params.token_metadata, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.metadata_program_id, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.system_program_id, is_signer=False, is_writable=False), + ] + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.CREATE_TOKEN_METADATA, + args={ + 'name': params.name, + 'symbol': params.symbol, + 'uri': params.uri + } + ) + ) + ) + + +def update_token_metadata(params: UpdateTokenMetadataParams) -> Instruction: + """Creates an instruction to update metadata in the mpl token metadata program account for the pool token.""" + + accounts = [ + AccountMeta(pubkey=params.stake_pool, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.manager, is_signer=True, is_writable=False), + AccountMeta(pubkey=params.withdraw_authority, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.token_metadata, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.metadata_program_id, is_signer=False, is_writable=False) + ] + return Instruction( + accounts=accounts, + program_id=params.program_id, + data=INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.UPDATE_TOKEN_METADATA, + args={ + "name": params.name, + "symbol": params.symbol, + "uri": params.uri + } + ) + ) + ) diff --git a/clients/py/stake_pool/state.py b/clients/py/stake_pool/state.py new file mode 100644 index 00000000..b0bc5cbc --- /dev/null +++ b/clients/py/stake_pool/state.py @@ -0,0 +1,327 @@ +"""SPL Stake Pool State.""" + +from enum import IntEnum +from typing import List, NamedTuple, Optional +from construct import Bytes, Container, Struct, Switch, Int8ul, Int32ul, Int64ul, Pass # type: ignore + +from solders.pubkey import Pubkey +from stake.state import Lockup, LOCKUP_LAYOUT + +PUBLIC_KEY_LAYOUT = Bytes(32) + + +def decode_optional_publickey(container: Container) -> Optional[Pubkey]: + if container: + return Pubkey(container.popitem()[1]) + else: + return None + + +class Fee(NamedTuple): + """Fee assessed by the stake pool, expressed as numerator / denominator.""" + numerator: int + denominator: int + + @classmethod + def decode_container(cls, container: Container): + return Fee( + numerator=container['numerator'], + denominator=container['denominator'], + ) + + @classmethod + def decode_optional_container(cls, container: Container): + if container: + return cls.decode_container(container) + else: + return None + + +class StakePool(NamedTuple): + """Stake pool and all its data.""" + manager: Pubkey + staker: Pubkey + stake_deposit_authority: Pubkey + stake_withdraw_bump_seed: int + validator_list: Pubkey + reserve_stake: Pubkey + pool_mint: Pubkey + manager_fee_account: Pubkey + token_program_id: Pubkey + total_lamports: int + pool_token_supply: int + last_update_epoch: int + lockup: Lockup + epoch_fee: Fee + next_epoch_fee: Optional[Fee] + preferred_deposit_validator: Optional[Pubkey] + preferred_withdraw_validator: Optional[Pubkey] + stake_deposit_fee: Fee + stake_withdrawal_fee: Fee + next_stake_withdrawal_fee: Optional[Fee] + stake_referral_fee: int + sol_deposit_authority: Optional[Pubkey] + sol_deposit_fee: Fee + sol_referral_fee: int + sol_withdraw_authority: Optional[Pubkey] + sol_withdrawal_fee: Fee + next_sol_withdrawal_fee: Optional[Fee] + last_epoch_pool_token_supply: int + last_epoch_total_lamports: int + + @classmethod + def decode(cls, data: bytes): + parsed = DECODE_STAKE_POOL_LAYOUT.parse(data) + return StakePool( + manager=Pubkey(parsed['manager']), + staker=Pubkey(parsed['staker']), + stake_deposit_authority=Pubkey(parsed['stake_deposit_authority']), + stake_withdraw_bump_seed=parsed['stake_withdraw_bump_seed'], + validator_list=Pubkey(parsed['validator_list']), + reserve_stake=Pubkey(parsed['reserve_stake']), + pool_mint=Pubkey(parsed['pool_mint']), + manager_fee_account=Pubkey(parsed['manager_fee_account']), + token_program_id=Pubkey(parsed['token_program_id']), + total_lamports=parsed['total_lamports'], + pool_token_supply=parsed['pool_token_supply'], + last_update_epoch=parsed['last_update_epoch'], + lockup=Lockup.decode_container(parsed['lockup']), + epoch_fee=Fee.decode_container(parsed['epoch_fee']), + next_epoch_fee=Fee.decode_optional_container(parsed['next_epoch_fee']), + preferred_deposit_validator=decode_optional_publickey(parsed['preferred_deposit_validator']), + preferred_withdraw_validator=decode_optional_publickey(parsed['preferred_withdraw_validator']), + stake_deposit_fee=Fee.decode_container(parsed['stake_deposit_fee']), + stake_withdrawal_fee=Fee.decode_container(parsed['stake_withdrawal_fee']), + next_stake_withdrawal_fee=Fee.decode_optional_container(parsed['next_stake_withdrawal_fee']), + stake_referral_fee=parsed['stake_referral_fee'], + sol_deposit_authority=decode_optional_publickey(parsed['sol_deposit_authority']), + sol_deposit_fee=Fee.decode_container(parsed['sol_deposit_fee']), + sol_referral_fee=parsed['sol_referral_fee'], + sol_withdraw_authority=decode_optional_publickey(parsed['sol_withdraw_authority']), + sol_withdrawal_fee=Fee.decode_container(parsed['sol_withdrawal_fee']), + next_sol_withdrawal_fee=Fee.decode_optional_container(parsed['next_sol_withdrawal_fee']), + last_epoch_pool_token_supply=parsed['last_epoch_pool_token_supply'], + last_epoch_total_lamports=parsed['last_epoch_total_lamports'], + ) + + +class StakeStatus(IntEnum): + """Specifies the status of a stake on a validator in a stake pool.""" + + ACTIVE = 0 + """Stake is active and normal.""" + DEACTIVATING_TRANSIENT = 1 + """Stake has been removed, but a deactivating transient stake still exists.""" + READY_FOR_REMOVAL = 2 + """No more validator stake accounts exist, entry ready for removal.""" + DEACTIVATING_VALIDATOR = 3 + """Validator stake account is deactivating to be merged into the reserve next epoch.""" + DEACTIVATING_ALL = 3 + """All alidator stake accounts are deactivating to be merged into the reserve next epoch.""" + + +class ValidatorStakeInfo(NamedTuple): + active_stake_lamports: int + """Amount of active stake delegated to this validator.""" + + transient_stake_lamports: int + """Amount of transient stake delegated to this validator.""" + + last_update_epoch: int + """Last epoch the active and transient stake lamports fields were updated.""" + + transient_seed_suffix: int + """Transient account seed suffix.""" + + unused: int + """Unused space, initially meant to specify the range of transient stake account suffixes.""" + + validator_seed_suffix: int + """Validator account seed suffix.""" + + status: StakeStatus + """Status of the validator stake account.""" + + vote_account_address: Pubkey + """Validator vote account address.""" + + @classmethod + def decode_container(cls, container: Container): + return ValidatorStakeInfo( + active_stake_lamports=container['active_stake_lamports'], + transient_stake_lamports=container['transient_stake_lamports'], + last_update_epoch=container['last_update_epoch'], + transient_seed_suffix=container['transient_seed_suffix'], + unused=container['unused'], + validator_seed_suffix=container['validator_seed_suffix'], + status=container['status'], + vote_account_address=Pubkey(container['vote_account_address']), + ) + + +class ValidatorList(NamedTuple): + """List of validators and amount staked, associated to a stake pool.""" + + max_validators: int + """Maximum number of validators possible in the list.""" + + validators: List[ValidatorStakeInfo] + """Info for each validator in the stake pool.""" + + @staticmethod + def calculate_validator_list_size(max_validators: int) -> int: + layout = VALIDATOR_LIST_LAYOUT + VALIDATOR_INFO_LAYOUT[max_validators] + return layout.sizeof() + + @classmethod + def decode(cls, data: bytes): + parsed = DECODE_VALIDATOR_LIST_LAYOUT.parse(data) + return ValidatorList( + max_validators=parsed['max_validators'], + validators=[ValidatorStakeInfo.decode_container(container) for container in parsed['validators']], + ) + + +FEE_LAYOUT = Struct( + "denominator" / Int64ul, + "numerator" / Int64ul, +) + +STAKE_POOL_LAYOUT = Struct( + "account_type" / Int8ul, + "manager" / PUBLIC_KEY_LAYOUT, + "staker" / PUBLIC_KEY_LAYOUT, + "stake_deposit_authority" / PUBLIC_KEY_LAYOUT, + "stake_withdraw_bump_seed" / Int8ul, + "validator_list" / PUBLIC_KEY_LAYOUT, + "reserve_stake" / PUBLIC_KEY_LAYOUT, + "pool_mint" / PUBLIC_KEY_LAYOUT, + "manager_fee_account" / PUBLIC_KEY_LAYOUT, + "token_program_id" / PUBLIC_KEY_LAYOUT, + "total_lamports" / Int64ul, + "pool_token_supply" / Int64ul, + "last_update_epoch" / Int64ul, + "lockup" / LOCKUP_LAYOUT, + "epoch_fee" / FEE_LAYOUT, + "next_epoch_fee_option" / Int8ul, + "next_epoch_fee" / FEE_LAYOUT, + "preferred_deposit_validator_option" / Int8ul, + "preferred_deposit_validator" / PUBLIC_KEY_LAYOUT, + "preferred_withdraw_validator_option" / Int8ul, + "preferred_withdraw_validator" / PUBLIC_KEY_LAYOUT, + "stake_deposit_fee" / FEE_LAYOUT, + "stake_withdrawal_fee" / FEE_LAYOUT, + "next_stake_withdrawal_fee_option" / Int8ul, + "next_stake_withdrawal_fee" / FEE_LAYOUT, + "stake_referral_fee" / Int8ul, + "sol_deposit_authority_option" / Int8ul, + "sol_deposit_authority" / PUBLIC_KEY_LAYOUT, + "sol_deposit_fee" / FEE_LAYOUT, + "sol_referral_fee" / Int8ul, + "sol_withdraw_authority_option" / Int8ul, + "sol_withdraw_authority" / PUBLIC_KEY_LAYOUT, + "sol_withdrawal_fee" / FEE_LAYOUT, + "next_sol_withdrawal_fee_option" / Int8ul, + "next_sol_withdrawal_fee" / FEE_LAYOUT, + "last_epoch_pool_token_supply" / Int64ul, + "last_epoch_total_lamports" / Int64ul, +) + +DECODE_STAKE_POOL_LAYOUT = Struct( + "account_type" / Int8ul, + "manager" / PUBLIC_KEY_LAYOUT, + "staker" / PUBLIC_KEY_LAYOUT, + "stake_deposit_authority" / PUBLIC_KEY_LAYOUT, + "stake_withdraw_bump_seed" / Int8ul, + "validator_list" / PUBLIC_KEY_LAYOUT, + "reserve_stake" / PUBLIC_KEY_LAYOUT, + "pool_mint" / PUBLIC_KEY_LAYOUT, + "manager_fee_account" / PUBLIC_KEY_LAYOUT, + "token_program_id" / PUBLIC_KEY_LAYOUT, + "total_lamports" / Int64ul, + "pool_token_supply" / Int64ul, + "last_update_epoch" / Int64ul, + "lockup" / LOCKUP_LAYOUT, + "epoch_fee" / FEE_LAYOUT, + "next_epoch_fee_option" / Int8ul, + "next_epoch_fee" / Switch( + lambda this: this.next_epoch_fee_option, + { + 0: Pass, + 1: FEE_LAYOUT, + }), + "preferred_deposit_validator_option" / Int8ul, + "preferred_deposit_validator" / Switch( + lambda this: this.preferred_deposit_validator_option, + { + 0: Pass, + 1: PUBLIC_KEY_LAYOUT, + }), + "preferred_withdraw_validator_option" / Int8ul, + "preferred_withdraw_validator" / Switch( + lambda this: this.preferred_withdraw_validator_option, + { + 0: Pass, + 1: PUBLIC_KEY_LAYOUT, + }), + "stake_deposit_fee" / FEE_LAYOUT, + "stake_withdrawal_fee" / FEE_LAYOUT, + "next_stake_withdrawal_fee_option" / Int8ul, + "next_stake_withdrawal_fee" / Switch( + lambda this: this.next_stake_withdrawal_fee_option, + { + 0: Pass, + 1: FEE_LAYOUT, + }), + "stake_referral_fee" / Int8ul, + "sol_deposit_authority_option" / Int8ul, + "sol_deposit_authority" / Switch( + lambda this: this.sol_deposit_authority_option, + { + 0: Pass, + 1: PUBLIC_KEY_LAYOUT, + }), + "sol_deposit_fee" / FEE_LAYOUT, + "sol_referral_fee" / Int8ul, + "sol_withdraw_authority_option" / Int8ul, + "sol_withdraw_authority" / Switch( + lambda this: this.sol_withdraw_authority_option, + { + 0: Pass, + 1: PUBLIC_KEY_LAYOUT, + }), + "sol_withdrawal_fee" / FEE_LAYOUT, + "next_sol_withdrawal_fee_option" / Int8ul, + "next_sol_withdrawal_fee" / Switch( + lambda this: this.next_sol_withdrawal_fee_option, + { + 0: Pass, + 1: FEE_LAYOUT, + }), + "last_epoch_pool_token_supply" / Int64ul, + "last_epoch_total_lamports" / Int64ul, +) + +VALIDATOR_INFO_LAYOUT = Struct( + "active_stake_lamports" / Int64ul, + "transient_stake_lamports" / Int64ul, + "last_update_epoch" / Int64ul, + "transient_seed_suffix" / Int64ul, + "unused" / Int32ul, + "validator_seed_suffix" / Int32ul, + "status" / Int8ul, + "vote_account_address" / PUBLIC_KEY_LAYOUT, +) + +VALIDATOR_LIST_LAYOUT = Struct( + "account_type" / Int8ul, + "max_validators" / Int32ul, + "validators_len" / Int32ul, +) + +DECODE_VALIDATOR_LIST_LAYOUT = Struct( + "account_type" / Int8ul, + "max_validators" / Int32ul, + "validators_len" / Int32ul, + "validators" / VALIDATOR_INFO_LAYOUT[lambda this: this.validators_len], +) diff --git a/clients/py/system/__init__.py b/clients/py/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/system/actions.py b/clients/py/system/actions.py new file mode 100644 index 00000000..c4c02549 --- /dev/null +++ b/clients/py/system/actions.py @@ -0,0 +1,8 @@ +from solders.pubkey import Pubkey +from solana.rpc.async_api import AsyncClient + + +async def airdrop(client: AsyncClient, receiver: Pubkey, lamports: int): + print(f"Airdropping {lamports} lamports to {receiver}...") + resp = await client.request_airdrop(receiver, lamports) + await client.confirm_transaction(resp.value) diff --git a/clients/py/tests/conftest.py b/clients/py/tests/conftest.py new file mode 100644 index 00000000..647e9594 --- /dev/null +++ b/clients/py/tests/conftest.py @@ -0,0 +1,121 @@ +import asyncio +import pytest +import pytest_asyncio +import os +import shutil +import tempfile +from typing import AsyncIterator, List, Tuple +from subprocess import Popen + +from solders.keypair import Keypair +from solders.pubkey import Pubkey +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed + +from spl.token.instructions import get_associated_token_address + +from vote.actions import create_vote +from system.actions import airdrop +from stake_pool.actions import deposit_sol, create_all, add_validator_to_pool +from stake_pool.state import Fee + +NUM_SLOTS_PER_EPOCH: int = 32 +AIRDROP_LAMPORTS: int = 30_000_000_000 + + +@pytest.fixture(scope="session") +def solana_test_validator(): + old_cwd = os.getcwd() + newpath = tempfile.mkdtemp() + os.chdir(newpath) + validator = Popen([ + "solana-test-validator", + "--reset", "--quiet", + "--bpf-program", "SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy", + f"{old_cwd}/../../target/deploy/spl_stake_pool.so", + "--bpf-program", "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + f"{old_cwd}/../program/tests/fixtures/mpl_token_metadata.so", + "--slots-per-epoch", str(NUM_SLOTS_PER_EPOCH), + ],) + yield + validator.kill() + os.chdir(old_cwd) + shutil.rmtree(newpath) + + +@pytest_asyncio.fixture +async def validators(async_client, payer) -> List[Pubkey]: + num_validators = 3 + validators = [] + for i in range(num_validators): + vote = Keypair() + node = Keypair() + await create_vote(async_client, payer, vote, node, payer.pubkey(), payer.pubkey(), 10) + validators.append(vote.pubkey()) + return validators + + +@pytest_asyncio.fixture +async def stake_pool_addresses( + async_client, payer, validators, waiter +) -> Tuple[Pubkey, Pubkey, Pubkey]: + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await waiter.wait_for_next_epoch_if_soon(async_client) + stake_pool_addresses = await create_all(async_client, payer, fee, referral_fee) + stake_pool = stake_pool_addresses[0] + pool_mint = stake_pool_addresses[2] + token_account = get_associated_token_address(payer.pubkey(), pool_mint) + await deposit_sol(async_client, payer, stake_pool, token_account, AIRDROP_LAMPORTS // 2) + for validator in validators: + await add_validator_to_pool(async_client, payer, stake_pool, validator) + return stake_pool_addresses + + +@pytest_asyncio.fixture +async def async_client(solana_test_validator) -> AsyncIterator[AsyncClient]: + async_client = AsyncClient(commitment=Confirmed) + total_attempts = 20 + current_attempt = 0 + while not await async_client.is_connected(): + if current_attempt == total_attempts: + raise Exception("Could not connect to test validator") + else: + current_attempt += 1 + await asyncio.sleep(1.0) + yield async_client + await async_client.close() + + +@pytest_asyncio.fixture +async def payer(async_client) -> Keypair: + payer = Keypair() + await airdrop(async_client, payer.pubkey(), AIRDROP_LAMPORTS) + return payer + + +class Waiter: + @staticmethod + async def wait_for_next_epoch(async_client: AsyncClient): + resp = await async_client.get_epoch_info(commitment=Confirmed) + current_epoch = resp.value.epoch + next_epoch = current_epoch + while current_epoch == next_epoch: + await asyncio.sleep(1.0) + resp = await async_client.get_epoch_info(commitment=Confirmed) + next_epoch = resp.value.epoch + await asyncio.sleep(0.4) # wait one more block to avoid reward payout time + + @staticmethod + async def wait_for_next_epoch_if_soon(async_client: AsyncClient): + resp = await async_client.get_epoch_info(commitment=Confirmed) + if resp.value.slots_in_epoch - resp.value.slot_index < NUM_SLOTS_PER_EPOCH // 2: + await Waiter.wait_for_next_epoch(async_client) + return True + else: + return False + + +@pytest.fixture +def waiter() -> Waiter: + return Waiter() diff --git a/clients/py/tests/test_a_time_sensitive.py b/clients/py/tests/test_a_time_sensitive.py new file mode 100644 index 00000000..59e59e9e --- /dev/null +++ b/clients/py/tests/test_a_time_sensitive.py @@ -0,0 +1,103 @@ +"""Time sensitive test, so run it first out of the bunch.""" +import asyncio +import pytest +from solana.rpc.commitment import Confirmed +from spl.token.instructions import get_associated_token_address + +from stake.constants import STAKE_LEN +from stake_pool.actions import deposit_sol, decrease_validator_stake, increase_validator_stake, update_stake_pool +from stake_pool.constants import MINIMUM_ACTIVE_STAKE +from stake_pool.state import StakePool, ValidatorList + + +@pytest.mark.asyncio +async def test_increase_decrease_this_is_very_slow(async_client, validators, payer, stake_pool_addresses, waiter): + (stake_pool_address, validator_list_address, _) = stake_pool_addresses + + resp = await async_client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = resp.value + minimum_amount = MINIMUM_ACTIVE_STAKE + stake_rent_exemption + increase_amount = MINIMUM_ACTIVE_STAKE * 4 + decrease_amount = increase_amount // 2 + deposit_amount = (increase_amount + stake_rent_exemption) * len(validators) + + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + token_account = get_associated_token_address(payer.pubkey(), stake_pool.pool_mint) + await deposit_sol(async_client, payer, stake_pool_address, token_account, deposit_amount) + + # increase to all + futures = [ + increase_validator_stake(async_client, payer, payer, stake_pool_address, validator, increase_amount // 2) + for validator in validators + ] + await asyncio.gather(*futures) + + # validate the increase is now on the transient account + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.transient_stake_lamports == increase_amount // 2 + stake_rent_exemption + assert validator.active_stake_lamports == minimum_amount + + # increase the same amount to test the increase additional instruction + futures = [ + increase_validator_stake(async_client, payer, payer, stake_pool_address, validator, increase_amount // 2, + ephemeral_stake_seed=0) + for validator in validators + ] + await asyncio.gather(*futures) + + # validate the additional increase is now on the transient account + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.transient_stake_lamports == increase_amount + stake_rent_exemption * 2 + assert validator.active_stake_lamports == minimum_amount + + print("Waiting for epoch to roll over") + await waiter.wait_for_next_epoch(async_client) + await update_stake_pool(async_client, payer, stake_pool_address) + + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.last_update_epoch != 0 + assert validator.transient_stake_lamports == 0 + assert validator.active_stake_lamports == increase_amount + minimum_amount + stake_rent_exemption + + # decrease from all + futures = [ + decrease_validator_stake(async_client, payer, payer, stake_pool_address, validator, decrease_amount) + for validator in validators + ] + await asyncio.gather(*futures) + + # validate the decrease is now on the transient account + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.transient_stake_lamports == decrease_amount + stake_rent_exemption + assert validator.active_stake_lamports == increase_amount - decrease_amount + minimum_amount + \ + stake_rent_exemption + + # DO NOT test decrese additional instruction as it is confirmed NOT to be working as advertised + + # roll over one epoch and verify we have the balances that we expect + expected_active_stake_lamports = increase_amount - decrease_amount + minimum_amount + stake_rent_exemption + + print("Waiting for epoch to roll over") + await waiter.wait_for_next_epoch(async_client) + await update_stake_pool(async_client, payer, stake_pool_address) + + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.transient_stake_lamports == 0 + assert validator.active_stake_lamports == expected_active_stake_lamports diff --git a/clients/py/tests/test_add_remove.py b/clients/py/tests/test_add_remove.py new file mode 100644 index 00000000..abf47871 --- /dev/null +++ b/clients/py/tests/test_add_remove.py @@ -0,0 +1,35 @@ +import asyncio +import pytest +from solana.rpc.commitment import Confirmed + +from stake.constants import STAKE_LEN +from stake_pool.actions import remove_validator_from_pool +from stake_pool.constants import MINIMUM_ACTIVE_STAKE +from stake_pool.state import ValidatorList, StakeStatus + + +@pytest.mark.asyncio +async def test_add_remove_validators(async_client, validators, payer, stake_pool_addresses): + (stake_pool_address, validator_list_address, _) = stake_pool_addresses + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + assert len(validator_list.validators) == len(validators) + resp = await async_client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = resp.value + futures = [] + for validator_info in validator_list.validators: + assert validator_info.vote_account_address in validators + assert validator_info.active_stake_lamports == stake_rent_exemption + MINIMUM_ACTIVE_STAKE + assert validator_info.transient_stake_lamports == 0 + assert validator_info.status == StakeStatus.ACTIVE + futures.append( + remove_validator_from_pool(async_client, payer, stake_pool_address, validator_info.vote_account_address) + ) + await asyncio.gather(*futures) + + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator_info in validator_list.validators: + assert validator_info.status == StakeStatus.DEACTIVATING_VALIDATOR diff --git a/clients/py/tests/test_bot_rebalance.py b/clients/py/tests/test_bot_rebalance.py new file mode 100644 index 00000000..c7da3f82 --- /dev/null +++ b/clients/py/tests/test_bot_rebalance.py @@ -0,0 +1,86 @@ +"""Time sensitive test, so run it first out of the bunch.""" +import pytest +from solana.rpc.commitment import Confirmed +from spl.token.instructions import get_associated_token_address + +from stake.constants import STAKE_LEN, LAMPORTS_PER_SOL +from stake_pool.actions import deposit_sol +from stake_pool.constants import MINIMUM_ACTIVE_STAKE, MINIMUM_RESERVE_LAMPORTS +from stake_pool.state import StakePool, ValidatorList + +from bot.rebalance import rebalance + + +ENDPOINT: str = "http://127.0.0.1:8899" + + +@pytest.mark.asyncio +async def test_rebalance_this_is_very_slow(async_client, validators, payer, stake_pool_addresses, waiter): + (stake_pool_address, validator_list_address, _) = stake_pool_addresses + resp = await async_client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = resp.value + # With minimum delegation at MINIMUM_DELEGATION + rent-exemption, when + # decreasing, we'll need rent exemption + minimum delegation delegated to + # cover all movements + minimum_amount = MINIMUM_ACTIVE_STAKE + stake_rent_exemption + increase_amount = MINIMUM_ACTIVE_STAKE + stake_rent_exemption + deposit_amount = (increase_amount + stake_rent_exemption) * len(validators) + + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + total_lamports = stake_pool.total_lamports + deposit_amount + token_account = get_associated_token_address(payer.pubkey(), stake_pool.pool_mint) + await deposit_sol(async_client, payer, stake_pool_address, token_account, deposit_amount) + + # Test case 1: Increase everywhere + await rebalance(ENDPOINT, stake_pool_address, payer, 0.0) + + # should only have minimum left + resp = await async_client.get_account_info(stake_pool.reserve_stake, commitment=Confirmed) + assert resp.value.lamports == stake_rent_exemption + MINIMUM_RESERVE_LAMPORTS + + # should all be the same + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.active_stake_lamports == minimum_amount + assert validator.transient_stake_lamports == total_lamports / len(validators) - minimum_amount + + # Test case 2: Decrease everything back to reserve + print('Waiting for next epoch') + await waiter.wait_for_next_epoch(async_client) + max_in_reserve = total_lamports - minimum_amount * len(validators) + await rebalance(ENDPOINT, stake_pool_address, payer, max_in_reserve / LAMPORTS_PER_SOL) + + # should still only have minimum left + resp = await async_client.get_account_info(stake_pool.reserve_stake, commitment=Confirmed) + reserve_lamports = resp.value.lamports + assert reserve_lamports == stake_rent_exemption + MINIMUM_RESERVE_LAMPORTS + + # should all be decreasing now + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.active_stake_lamports == minimum_amount + assert validator.transient_stake_lamports == max_in_reserve / len(validators) + + # Test case 3: Do nothing + print('Waiting for next epoch') + await waiter.wait_for_next_epoch(async_client) + await rebalance(ENDPOINT, stake_pool_address, payer, max_in_reserve / LAMPORTS_PER_SOL) + + # should still only have minimum left + rent exemptions from increase + resp = await async_client.get_account_info(stake_pool.reserve_stake, commitment=Confirmed) + reserve_lamports = resp.value.lamports + assert reserve_lamports == stake_rent_exemption + max_in_reserve + MINIMUM_RESERVE_LAMPORTS + + # should all be decreased now + resp = await async_client.get_account_info(validator_list_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + validator_list = ValidatorList.decode(data) + for validator in validator_list.validators: + assert validator.active_stake_lamports == minimum_amount + assert validator.transient_stake_lamports == 0 diff --git a/clients/py/tests/test_create.py b/clients/py/tests/test_create.py new file mode 100644 index 00000000..3b5a42a1 --- /dev/null +++ b/clients/py/tests/test_create.py @@ -0,0 +1,71 @@ +import pytest +from solders.keypair import Keypair +from solana.rpc.commitment import Confirmed +from spl.token.constants import TOKEN_PROGRAM_ID + +from stake_pool.constants import \ + find_withdraw_authority_program_address, \ + MINIMUM_RESERVE_LAMPORTS, \ + STAKE_POOL_PROGRAM_ID +from stake_pool.state import StakePool, Fee + +from stake.actions import create_stake +from stake_pool.actions import create +from spl_token.actions import create_mint, create_associated_token_account + + +@pytest.mark.asyncio +async def test_create_stake_pool(async_client, payer): + stake_pool = Keypair() + validator_list = Keypair() + (pool_withdraw_authority, seed) = find_withdraw_authority_program_address( + STAKE_POOL_PROGRAM_ID, stake_pool.pubkey()) + + reserve_stake = Keypair() + await create_stake(async_client, payer, reserve_stake, pool_withdraw_authority, MINIMUM_RESERVE_LAMPORTS) + + pool_mint = Keypair() + await create_mint(async_client, payer, pool_mint, pool_withdraw_authority) + + manager_fee_account = await create_associated_token_account( + async_client, + payer, + payer.pubkey(), + pool_mint.pubkey(), + ) + + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await create( + async_client, payer, stake_pool, validator_list, pool_mint.pubkey(), + reserve_stake.pubkey(), manager_fee_account, fee, referral_fee) + resp = await async_client.get_account_info(stake_pool.pubkey(), commitment=Confirmed) + assert resp.value.owner == STAKE_POOL_PROGRAM_ID + data = resp.value.data if resp.value else bytes() + pool_data = StakePool.decode(data) + assert pool_data.manager == payer.pubkey() + assert pool_data.staker == payer.pubkey() + assert pool_data.stake_withdraw_bump_seed == seed + assert pool_data.validator_list == validator_list.pubkey() + assert pool_data.reserve_stake == reserve_stake.pubkey() + assert pool_data.pool_mint == pool_mint.pubkey() + assert pool_data.manager_fee_account == manager_fee_account + assert pool_data.token_program_id == TOKEN_PROGRAM_ID + assert pool_data.total_lamports == 0 + assert pool_data.pool_token_supply == 0 + assert pool_data.epoch_fee == fee + assert pool_data.next_epoch_fee is None + assert pool_data.preferred_deposit_validator is None + assert pool_data.preferred_withdraw_validator is None + assert pool_data.stake_deposit_fee == fee + assert pool_data.stake_withdrawal_fee == fee + assert pool_data.next_stake_withdrawal_fee is None + assert pool_data.stake_referral_fee == referral_fee + assert pool_data.sol_deposit_authority is None + assert pool_data.sol_deposit_fee == fee + assert pool_data.sol_referral_fee == referral_fee + assert pool_data.sol_withdraw_authority is None + assert pool_data.sol_withdrawal_fee == fee + assert pool_data.next_sol_withdrawal_fee is None + assert pool_data.last_epoch_pool_token_supply == 0 + assert pool_data.last_epoch_total_lamports == 0 diff --git a/clients/py/tests/test_create_update_token_metadata.py b/clients/py/tests/test_create_update_token_metadata.py new file mode 100644 index 00000000..2ee0a7db --- /dev/null +++ b/clients/py/tests/test_create_update_token_metadata.py @@ -0,0 +1,63 @@ +import pytest +from stake_pool.actions import create_all, create_token_metadata, update_token_metadata +from stake_pool.state import Fee, StakePool +from solana.rpc.commitment import Confirmed +from stake_pool.constants import find_metadata_account + + +@pytest.mark.asyncio +async def test_create_metadata_success(async_client, waiter, payer): + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await waiter.wait_for_next_epoch_if_soon(async_client) + (stake_pool_address, _validator_list_address, _) = await create_all(async_client, payer, fee, referral_fee) + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + name = "test_name" + symbol = "SYM" + uri = "test_uri" + await create_token_metadata(async_client, payer, stake_pool_address, name, symbol, uri) + + (metadata_program_address, _seed) = find_metadata_account(stake_pool.pool_mint) + resp = await async_client.get_account_info(metadata_program_address, commitment=Confirmed) + raw_data = resp.value.data if resp.value else bytes() + assert name == str(raw_data[69:101], "utf-8")[:len(name)] + assert symbol == str(raw_data[105:115], "utf-8")[:len(symbol)] + assert uri == str(raw_data[119:319], "utf-8")[:len(uri)] + + +@pytest.mark.asyncio +async def test_update_metadata_success(async_client, waiter, payer): + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await waiter.wait_for_next_epoch_if_soon(async_client) + (stake_pool_address, _validator_list_address, _) = await create_all(async_client, payer, fee, referral_fee) + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + + name = "test_name" + symbol = "SYM" + uri = "test_uri" + await create_token_metadata(async_client, payer, stake_pool_address, name, symbol, uri) + + (metadata_program_address, _seed) = find_metadata_account(stake_pool.pool_mint) + resp = await async_client.get_account_info(metadata_program_address, commitment=Confirmed) + raw_data = resp.value.data if resp.value else bytes() + assert name == str(raw_data[69:101], "utf-8")[:len(name)] + assert symbol == str(raw_data[105:115], "utf-8")[:len(symbol)] + assert uri == str(raw_data[119:319], "utf-8")[:len(uri)] + + updated_name = "updated_name" + updated_symbol = "USM" + updated_uri = "updated_uri" + await update_token_metadata(async_client, payer, stake_pool_address, updated_name, updated_symbol, updated_uri) + + (metadata_program_address, _seed) = find_metadata_account(stake_pool.pool_mint) + resp = await async_client.get_account_info(metadata_program_address, commitment=Confirmed) + raw_data = resp.value.data if resp.value else bytes() + assert updated_name == str(raw_data[69:101], "utf-8")[:len(updated_name)] + assert updated_symbol == str(raw_data[105:115], "utf-8")[:len(updated_symbol)] + assert updated_uri == str(raw_data[119:319], "utf-8")[:len(updated_uri)] diff --git a/clients/py/tests/test_deposit_withdraw_sol.py b/clients/py/tests/test_deposit_withdraw_sol.py new file mode 100644 index 00000000..438c4afd --- /dev/null +++ b/clients/py/tests/test_deposit_withdraw_sol.py @@ -0,0 +1,28 @@ +import pytest +from solana.rpc.commitment import Confirmed, Processed +from solders.keypair import Keypair +from spl.token.instructions import get_associated_token_address + +from stake_pool.state import Fee, StakePool +from stake_pool.actions import create_all, deposit_sol, withdraw_sol + + +@pytest.mark.asyncio +async def test_deposit_withdraw_sol(async_client, waiter, payer): + fee = Fee(numerator=1, denominator=1000) + referral_fee = 20 + await waiter.wait_for_next_epoch(async_client) + (stake_pool_address, validator_list_address, _) = await create_all(async_client, payer, fee, referral_fee) + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + token_account = get_associated_token_address(payer.pubkey(), stake_pool.pool_mint) + deposit_amount = 100_000_000 + await deposit_sol(async_client, payer, stake_pool_address, token_account, deposit_amount) + pool_token_balance = await async_client.get_token_account_balance(token_account, Confirmed) + assert pool_token_balance.value.amount == str(deposit_amount) + recipient = Keypair() + await withdraw_sol(async_client, payer, token_account, stake_pool_address, recipient.pubkey(), deposit_amount) + # for some reason, this is not always in sync when running all tests + pool_token_balance = await async_client.get_token_account_balance(token_account, Processed) + assert pool_token_balance.value.amount == str('0') diff --git a/clients/py/tests/test_deposit_withdraw_stake.py b/clients/py/tests/test_deposit_withdraw_stake.py new file mode 100644 index 00000000..63b9e07d --- /dev/null +++ b/clients/py/tests/test_deposit_withdraw_stake.py @@ -0,0 +1,52 @@ +import pytest +from solana.rpc.commitment import Confirmed +from solders.keypair import Keypair +from spl.token.instructions import get_associated_token_address + +from stake.actions import create_stake, delegate_stake +from stake.constants import STAKE_LEN +from stake.state import StakeStake +from stake_pool.actions import deposit_stake, withdraw_stake, update_stake_pool +from stake_pool.constants import MINIMUM_ACTIVE_STAKE +from stake_pool.state import StakePool + + +@pytest.mark.asyncio +async def test_deposit_withdraw_stake(async_client, validators, payer, stake_pool_addresses, waiter): + (stake_pool_address, validator_list_address, _) = stake_pool_addresses + resp = await async_client.get_account_info(stake_pool_address, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_pool = StakePool.decode(data) + validator = next(iter(validators)) + stake_amount = MINIMUM_ACTIVE_STAKE + stake = Keypair() + await create_stake(async_client, payer, stake, payer.pubkey(), stake_amount) + stake = stake.pubkey() + await delegate_stake(async_client, payer, payer, stake, validator) + resp = await async_client.get_account_info(stake, commitment=Confirmed) + data = resp.value.data if resp.value else bytes() + stake_state = StakeStake.decode(data) + token_account = get_associated_token_address(payer.pubkey(), stake_pool.pool_mint) + pre_pool_token_balance = await async_client.get_token_account_balance(token_account, Confirmed) + pre_pool_token_balance = int(pre_pool_token_balance.value.amount) + print(stake_state) + + await waiter.wait_for_next_epoch(async_client) + + await update_stake_pool(async_client, payer, stake_pool_address) + await deposit_stake(async_client, payer, stake_pool_address, validator, stake, token_account) + pool_token_balance = await async_client.get_token_account_balance(token_account, Confirmed) + pool_token_balance = pool_token_balance.value.amount + resp = await async_client.get_minimum_balance_for_rent_exemption(STAKE_LEN) + stake_rent_exemption = resp.value + assert pool_token_balance == str(stake_amount + stake_rent_exemption + pre_pool_token_balance) + + destination_stake = Keypair() + await withdraw_stake( + async_client, payer, payer, destination_stake, stake_pool_address, validator, + payer.pubkey(), token_account, stake_amount + ) + + pool_token_balance = await async_client.get_token_account_balance(token_account, Confirmed) + pool_token_balance = pool_token_balance.value.amount + assert pool_token_balance == str(stake_rent_exemption + pre_pool_token_balance) diff --git a/clients/py/tests/test_stake.py b/clients/py/tests/test_stake.py new file mode 100644 index 00000000..7f89b8a7 --- /dev/null +++ b/clients/py/tests/test_stake.py @@ -0,0 +1,33 @@ +import asyncio +import pytest +from solders.keypair import Keypair + +from stake.actions import authorize, create_stake, delegate_stake +from stake.constants import MINIMUM_DELEGATION +from stake.state import StakeAuthorize + + +@pytest.mark.asyncio +async def test_create_stake(async_client, payer): + stake = Keypair() + await create_stake(async_client, payer, stake, payer.pubkey(), 1) + + +@pytest.mark.asyncio +async def test_delegate_stake(async_client, validators, payer): + validator = validators[0] + stake = Keypair() + await create_stake(async_client, payer, stake, payer.pubkey(), MINIMUM_DELEGATION) + await delegate_stake(async_client, payer, payer, stake.pubkey(), validator) + + +@pytest.mark.asyncio +async def test_authorize_stake(async_client, payer): + stake = Keypair() + new_authority = Keypair() + await create_stake(async_client, payer, stake, payer.pubkey(), MINIMUM_DELEGATION) + await asyncio.gather( + authorize(async_client, payer, payer, stake.pubkey(), new_authority.pubkey(), StakeAuthorize.STAKER), + authorize(async_client, payer, payer, stake.pubkey(), new_authority.pubkey(), StakeAuthorize.WITHDRAWER) + ) + await authorize(async_client, payer, new_authority, stake.pubkey(), payer.pubkey(), StakeAuthorize.WITHDRAWER) diff --git a/clients/py/tests/test_system.py b/clients/py/tests/test_system.py new file mode 100644 index 00000000..f3b578ae --- /dev/null +++ b/clients/py/tests/test_system.py @@ -0,0 +1,14 @@ +import pytest +from solders.keypair import Keypair +from solana.rpc.commitment import Confirmed + +import system.actions + + +@pytest.mark.asyncio +async def test_airdrop(async_client): + manager = Keypair() + airdrop_lamports = 1_000_000 + await system.actions.airdrop(async_client, manager.pubkey(), airdrop_lamports) + resp = await async_client.get_balance(manager.pubkey(), commitment=Confirmed) + assert resp.value == airdrop_lamports diff --git a/clients/py/tests/test_token.py b/clients/py/tests/test_token.py new file mode 100644 index 00000000..835feb15 --- /dev/null +++ b/clients/py/tests/test_token.py @@ -0,0 +1,16 @@ +import pytest +from solders.keypair import Keypair + +from spl_token.actions import create_mint, create_associated_token_account + + +@pytest.mark.asyncio +async def test_create_mint(async_client, payer): + pool_mint = Keypair() + await create_mint(async_client, payer, pool_mint, payer.pubkey()) + await create_associated_token_account( + async_client, + payer, + payer.pubkey(), + pool_mint.pubkey(), + ) diff --git a/clients/py/tests/test_vote.py b/clients/py/tests/test_vote.py new file mode 100644 index 00000000..3b3ff460 --- /dev/null +++ b/clients/py/tests/test_vote.py @@ -0,0 +1,15 @@ +import pytest +from solders.keypair import Keypair +from solana.rpc.commitment import Confirmed + +from vote.actions import create_vote +from vote.constants import VOTE_PROGRAM_ID + + +@pytest.mark.asyncio +async def test_create_vote(async_client, payer): + vote = Keypair() + node = Keypair() + await create_vote(async_client, payer, vote, node, payer.pubkey(), payer.pubkey(), 10) + resp = await async_client.get_account_info(vote.pubkey(), commitment=Confirmed) + assert resp.value.owner == VOTE_PROGRAM_ID diff --git a/clients/py/vote/__init__.py b/clients/py/vote/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/clients/py/vote/actions.py b/clients/py/vote/actions.py new file mode 100644 index 00000000..305d79a4 --- /dev/null +++ b/clients/py/vote/actions.py @@ -0,0 +1,47 @@ +from solders.pubkey import Pubkey +from solders.keypair import Keypair +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Confirmed +from solana.rpc.types import TxOpts +from solders.sysvar import CLOCK, RENT +from solana.transaction import Transaction +import solders.system_program as sys + +from vote.constants import VOTE_PROGRAM_ID, VOTE_STATE_LEN +from vote.instructions import initialize, InitializeParams + + +async def create_vote( + client: AsyncClient, payer: Keypair, vote: Keypair, node: Keypair, + voter: Pubkey, withdrawer: Pubkey, commission: int): + print(f"Creating vote account {vote.pubkey()}") + resp = await client.get_minimum_balance_for_rent_exemption(VOTE_STATE_LEN) + txn = Transaction(fee_payer=payer.pubkey()) + txn.add( + sys.create_account( + sys.CreateAccountParams( + from_pubkey=payer.pubkey(), + to_pubkey=vote.pubkey(), + lamports=resp.value, + space=VOTE_STATE_LEN, + owner=VOTE_PROGRAM_ID, + ) + ) + ) + txn.add( + initialize( + InitializeParams( + vote=vote.pubkey(), + rent_sysvar=RENT, + clock_sysvar=CLOCK, + node=node.pubkey(), + authorized_voter=voter, + authorized_withdrawer=withdrawer, + commission=commission, + ) + ) + ) + recent_blockhash = (await client.get_latest_blockhash()).value.blockhash + await client.send_transaction( + txn, payer, vote, node, recent_blockhash=recent_blockhash, + opts=TxOpts(skip_confirmation=False, preflight_commitment=Confirmed)) diff --git a/clients/py/vote/constants.py b/clients/py/vote/constants.py new file mode 100644 index 00000000..0da3846b --- /dev/null +++ b/clients/py/vote/constants.py @@ -0,0 +1,8 @@ +from solders.pubkey import Pubkey + + +VOTE_PROGRAM_ID = Pubkey.from_string("Vote111111111111111111111111111111111111111") +"""Program id for the native vote program.""" + +VOTE_STATE_LEN: int = 3762 +"""Size of vote account.""" diff --git a/clients/py/vote/instructions.py b/clients/py/vote/instructions.py new file mode 100644 index 00000000..c8a0a69d --- /dev/null +++ b/clients/py/vote/instructions.py @@ -0,0 +1,98 @@ +"""Vote Program Instructions.""" + +from enum import IntEnum +from typing import NamedTuple + +from construct import Bytes, Struct, Switch, Int8ul, Int32ul, Pass # type: ignore + +from solders.pubkey import Pubkey +from solders.sysvar import CLOCK, RENT +from solders.instruction import AccountMeta, Instruction + +from vote.constants import VOTE_PROGRAM_ID + +PUBLIC_KEY_LAYOUT = Bytes(32) + + +class InitializeParams(NamedTuple): + """Initialize vote account params.""" + + vote: Pubkey + """`[w]` Uninitialized vote account""" + rent_sysvar: Pubkey + """`[]` Rent sysvar.""" + clock_sysvar: Pubkey + """`[]` Clock sysvar.""" + node: Pubkey + """`[s]` New validator identity.""" + + authorized_voter: Pubkey + """The authorized voter for this vote account.""" + authorized_withdrawer: Pubkey + """The authorized withdrawer for this vote account.""" + commission: int + """Commission, represented as a percentage""" + + +class InstructionType(IntEnum): + """Vote Instruction Types.""" + + INITIALIZE = 0 + AUTHORIZE = 1 + VOTE = 2 + WITHDRAW = 3 + UPDATE_VALIDATOR_IDENTITY = 4 + UPDATE_COMMISSION = 5 + VOTE_SWITCH = 6 + AUTHORIZE_CHECKED = 7 + + +INITIALIZE_LAYOUT = Struct( + "node" / PUBLIC_KEY_LAYOUT, + "authorized_voter" / PUBLIC_KEY_LAYOUT, + "authorized_withdrawer" / PUBLIC_KEY_LAYOUT, + "commission" / Int8ul, +) + +INSTRUCTIONS_LAYOUT = Struct( + "instruction_type" / Int32ul, + "args" + / Switch( + lambda this: this.instruction_type, + { + InstructionType.INITIALIZE: INITIALIZE_LAYOUT, + InstructionType.AUTHORIZE: Pass, # TODO + InstructionType.VOTE: Pass, # TODO + InstructionType.WITHDRAW: Pass, # TODO + InstructionType.UPDATE_VALIDATOR_IDENTITY: Pass, # TODO + InstructionType.UPDATE_COMMISSION: Pass, # TODO + InstructionType.VOTE_SWITCH: Pass, # TODO + InstructionType.AUTHORIZE_CHECKED: Pass, # TODO + }, + ), +) + + +def initialize(params: InitializeParams) -> Instruction: + """Creates a transaction instruction to initialize a new stake.""" + data = INSTRUCTIONS_LAYOUT.build( + dict( + instruction_type=InstructionType.INITIALIZE, + args=dict( + node=bytes(params.node), + authorized_voter=bytes(params.authorized_voter), + authorized_withdrawer=bytes(params.authorized_withdrawer), + commission=params.commission, + ), + ) + ) + return Instruction( + program_id=VOTE_PROGRAM_ID, + accounts=[ + AccountMeta(pubkey=params.vote, is_signer=False, is_writable=True), + AccountMeta(pubkey=params.rent_sysvar or RENT, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.clock_sysvar or CLOCK, is_signer=False, is_writable=False), + AccountMeta(pubkey=params.node, is_signer=True, is_writable=False), + ], + data=data, + ) diff --git a/program/Cargo.toml b/program/Cargo.toml new file mode 100644 index 00000000..b34fb69a --- /dev/null +++ b/program/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "spl-stake-pool" +version = "2.0.1" +description = "Solana Program Library Stake Pool" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +no-entrypoint = [] +test-sbf = [] + +[dependencies] +arrayref = "0.3.9" +borsh = "1.5.3" +bytemuck = "1.20" +num-derive = "0.4" +num-traits = "0.2" +num_enum = "0.7.3" +serde = "1.0.215" +serde_derive = "1.0.103" +solana-program = "2.1.0" +solana-security-txt = "1.1.1" +spl-pod = { version = "0.5.0", path = "../../libraries/pod", features = [ + "borsh", +] } +spl-token-2022 = { version = "6.0.0", path = "../../token/program-2022", features = [ + "no-entrypoint", +] } +thiserror = "2.0" +bincode = "1.3.1" + +[dev-dependencies] +assert_matches = "1.5.0" +proptest = "1.5" +solana-program-test = "2.1.0" +solana-sdk = "2.1.0" +solana-vote-program = "2.1.0" +spl-token = { version = "7.0", path = "../../token/program", features = [ + "no-entrypoint", +] } +test-case = "3.3" + +[lib] +crate-type = ["cdylib", "lib"] + +[lints] +workspace = true diff --git a/program/Xargo.toml b/program/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/program/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/program/program-id.md b/program/program-id.md new file mode 100644 index 00000000..7b5157e4 --- /dev/null +++ b/program/program-id.md @@ -0,0 +1 @@ +SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy diff --git a/program/proptest-regressions/state.txt b/program/proptest-regressions/state.txt new file mode 100644 index 00000000..769e8a16 --- /dev/null +++ b/program/proptest-regressions/state.txt @@ -0,0 +1 @@ +cc 41ce1c46341336993e5e5d5aa94c30865e63f9e00d31d397aef88ec79fc312ca diff --git a/program/src/big_vec.rs b/program/src/big_vec.rs new file mode 100644 index 00000000..9ad84ff3 --- /dev/null +++ b/program/src/big_vec.rs @@ -0,0 +1,302 @@ +//! Big vector type, used with vectors that can't be serde'd +#![allow(clippy::arithmetic_side_effects)] // checked math involves too many compute units + +use { + arrayref::array_ref, + borsh::BorshDeserialize, + bytemuck::Pod, + solana_program::{program_error::ProgramError, program_memory::sol_memmove}, + std::mem, +}; + +/// Contains easy to use utilities for a big vector of Borsh-compatible types, +/// to avoid managing the entire struct on-chain and blow through stack limits. +pub struct BigVec<'data> { + /// Underlying data buffer, pieces of which are serialized + pub data: &'data mut [u8], +} + +const VEC_SIZE_BYTES: usize = 4; + +impl<'data> BigVec<'data> { + /// Get the length of the vector + pub fn len(&self) -> u32 { + let vec_len = array_ref![self.data, 0, VEC_SIZE_BYTES]; + u32::from_le_bytes(*vec_len) + } + + /// Find out if the vector has no contents (as demanded by clippy) + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Retain all elements that match the provided function, discard all others + pub fn retain bool>( + &mut self, + predicate: F, + ) -> Result<(), ProgramError> { + let mut vec_len = self.len(); + let mut removals_found = 0; + let mut dst_start_index = 0; + + let data_start_index = VEC_SIZE_BYTES; + let data_end_index = + data_start_index.saturating_add((vec_len as usize).saturating_mul(mem::size_of::())); + for start_index in (data_start_index..data_end_index).step_by(mem::size_of::()) { + let end_index = start_index + mem::size_of::(); + let slice = &self.data[start_index..end_index]; + if !predicate(slice) { + let gap = removals_found * mem::size_of::(); + if removals_found > 0 { + // In case the compute budget is ever bumped up, allowing us + // to use this safe code instead: + // self.data.copy_within(dst_start_index + gap..start_index, dst_start_index); + unsafe { + sol_memmove( + self.data[dst_start_index..start_index - gap].as_mut_ptr(), + self.data[dst_start_index + gap..start_index].as_mut_ptr(), + start_index - gap - dst_start_index, + ); + } + } + dst_start_index = start_index - gap; + removals_found += 1; + vec_len -= 1; + } + } + + // final memmove + if removals_found > 0 { + let gap = removals_found * mem::size_of::(); + // In case the compute budget is ever bumped up, allowing us + // to use this safe code instead: + // self.data.copy_within( + // dst_start_index + gap..data_end_index, + // dst_start_index, + // ); + unsafe { + sol_memmove( + self.data[dst_start_index..data_end_index - gap].as_mut_ptr(), + self.data[dst_start_index + gap..data_end_index].as_mut_ptr(), + data_end_index - gap - dst_start_index, + ); + } + } + + let vec_len_ref = &mut self.data[0..VEC_SIZE_BYTES]; + borsh::to_writer(vec_len_ref, &vec_len)?; + + Ok(()) + } + + /// Extracts a slice of the data types + pub fn deserialize_mut_slice( + &mut self, + skip: usize, + len: usize, + ) -> Result<&mut [T], ProgramError> { + let vec_len = self.len(); + let last_item_index = skip + .checked_add(len) + .ok_or(ProgramError::AccountDataTooSmall)?; + if last_item_index > vec_len as usize { + return Err(ProgramError::AccountDataTooSmall); + } + + let start_index = VEC_SIZE_BYTES.saturating_add(skip.saturating_mul(mem::size_of::())); + let end_index = start_index.saturating_add(len.saturating_mul(mem::size_of::())); + bytemuck::try_cast_slice_mut(&mut self.data[start_index..end_index]) + .map_err(|_| ProgramError::InvalidAccountData) + } + + /// Extracts a slice of the data types + pub fn deserialize_slice(&self, skip: usize, len: usize) -> Result<&[T], ProgramError> { + let vec_len = self.len(); + let last_item_index = skip + .checked_add(len) + .ok_or(ProgramError::AccountDataTooSmall)?; + if last_item_index > vec_len as usize { + return Err(ProgramError::AccountDataTooSmall); + } + + let start_index = VEC_SIZE_BYTES.saturating_add(skip.saturating_mul(mem::size_of::())); + let end_index = start_index.saturating_add(len.saturating_mul(mem::size_of::())); + bytemuck::try_cast_slice(&self.data[start_index..end_index]) + .map_err(|_| ProgramError::InvalidAccountData) + } + + /// Add new element to the end + pub fn push(&mut self, element: T) -> Result<(), ProgramError> { + let vec_len_ref = &mut self.data[0..VEC_SIZE_BYTES]; + let mut vec_len = u32::try_from_slice(vec_len_ref)?; + + let start_index = VEC_SIZE_BYTES + vec_len as usize * mem::size_of::(); + let end_index = start_index + mem::size_of::(); + + vec_len += 1; + borsh::to_writer(vec_len_ref, &vec_len)?; + + if self.data.len() < end_index { + return Err(ProgramError::AccountDataTooSmall); + } + let element_ref = bytemuck::try_from_bytes_mut( + &mut self.data[start_index..start_index + mem::size_of::()], + ) + .map_err(|_| ProgramError::InvalidAccountData)?; + *element_ref = element; + Ok(()) + } + + /// Find matching data in the array + pub fn find bool>(&self, predicate: F) -> Option<&T> { + let len = self.len() as usize; + let mut current = 0; + let mut current_index = VEC_SIZE_BYTES; + while current != len { + let end_index = current_index + mem::size_of::(); + let current_slice = &self.data[current_index..end_index]; + if predicate(current_slice) { + return Some(bytemuck::from_bytes(current_slice)); + } + current_index = end_index; + current += 1; + } + None + } + + /// Find matching data in the array + pub fn find_mut bool>(&mut self, predicate: F) -> Option<&mut T> { + let len = self.len() as usize; + let mut current = 0; + let mut current_index = VEC_SIZE_BYTES; + while current != len { + let end_index = current_index + mem::size_of::(); + let current_slice = &self.data[current_index..end_index]; + if predicate(current_slice) { + return Some(bytemuck::from_bytes_mut( + &mut self.data[current_index..end_index], + )); + } + current_index = end_index; + current += 1; + } + None + } +} + +#[cfg(test)] +mod tests { + use {super::*, bytemuck::Zeroable}; + + #[repr(C)] + #[derive(Debug, Copy, Clone, PartialEq, Pod, Zeroable)] + struct TestStruct { + value: [u8; 8], + } + + impl TestStruct { + fn new(value: u8) -> Self { + let value = [value, 0, 0, 0, 0, 0, 0, 0]; + Self { value } + } + } + + fn from_slice<'data>(data: &'data mut [u8], vec: &[u8]) -> BigVec<'data> { + let mut big_vec = BigVec { data }; + for element in vec { + big_vec.push(TestStruct::new(*element)).unwrap(); + } + big_vec + } + + fn check_big_vec_eq(big_vec: &BigVec, slice: &[u8]) { + assert!(big_vec + .deserialize_slice::(0, big_vec.len() as usize) + .unwrap() + .iter() + .map(|x| &x.value[0]) + .zip(slice.iter()) + .all(|(a, b)| a == b)); + } + + #[test] + fn push() { + let mut data = [0u8; 4 + 8 * 3]; + let mut v = BigVec { data: &mut data }; + v.push(TestStruct::new(1)).unwrap(); + check_big_vec_eq(&v, &[1]); + v.push(TestStruct::new(2)).unwrap(); + check_big_vec_eq(&v, &[1, 2]); + v.push(TestStruct::new(3)).unwrap(); + check_big_vec_eq(&v, &[1, 2, 3]); + assert_eq!( + v.push(TestStruct::new(4)).unwrap_err(), + ProgramError::AccountDataTooSmall + ); + } + + #[test] + fn retain() { + fn mod_2_predicate(data: &[u8]) -> bool { + u64::try_from_slice(data).unwrap() % 2 == 0 + } + + let mut data = [0u8; 4 + 8 * 4]; + let mut v = from_slice(&mut data, &[1, 2, 3, 4]); + v.retain::(mod_2_predicate).unwrap(); + check_big_vec_eq(&v, &[2, 4]); + } + + fn find_predicate(a: &[u8], b: u8) -> bool { + if a.len() != 8 { + false + } else { + a[0] == b + } + } + + #[test] + fn find() { + let mut data = [0u8; 4 + 8 * 4]; + let v = from_slice(&mut data, &[1, 2, 3, 4]); + assert_eq!( + v.find::(|x| find_predicate(x, 1)), + Some(&TestStruct::new(1)) + ); + assert_eq!( + v.find::(|x| find_predicate(x, 4)), + Some(&TestStruct::new(4)) + ); + assert_eq!(v.find::(|x| find_predicate(x, 5)), None); + } + + #[test] + fn find_mut() { + let mut data = [0u8; 4 + 8 * 4]; + let mut v = from_slice(&mut data, &[1, 2, 3, 4]); + let test_struct = v + .find_mut::(|x| find_predicate(x, 1)) + .unwrap(); + test_struct.value = [0; 8]; + check_big_vec_eq(&v, &[0, 2, 3, 4]); + assert_eq!(v.find_mut::(|x| find_predicate(x, 5)), None); + } + + #[test] + fn deserialize_mut_slice() { + let mut data = [0u8; 4 + 8 * 4]; + let mut v = from_slice(&mut data, &[1, 2, 3, 4]); + let slice = v.deserialize_mut_slice::(1, 2).unwrap(); + slice[0].value[0] = 10; + slice[1].value[0] = 11; + check_big_vec_eq(&v, &[1, 10, 11, 4]); + assert_eq!( + v.deserialize_mut_slice::(1, 4).unwrap_err(), + ProgramError::AccountDataTooSmall + ); + assert_eq!( + v.deserialize_mut_slice::(4, 1).unwrap_err(), + ProgramError::AccountDataTooSmall + ); + } +} diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs new file mode 100644 index 00000000..b616b219 --- /dev/null +++ b/program/src/entrypoint.rs @@ -0,0 +1,42 @@ +//! Program entrypoint + +#![cfg(all(target_os = "solana", not(feature = "no-entrypoint")))] + +use { + crate::{error::StakePoolError, processor::Processor}, + solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, program_error::PrintProgramError, + pubkey::Pubkey, + }, + solana_security_txt::security_txt, +}; + +solana_program::entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + if let Err(error) = Processor::process(program_id, accounts, instruction_data) { + // catch the error so we can print it + error.print::(); + Err(error) + } else { + Ok(()) + } +} + +security_txt! { + // Required fields + name: "SPL Stake Pool", + project_url: "https://spl.solana.com/stake-pool", + contacts: "link:https://github.com/solana-labs/solana-program-library/security/advisories/new,mailto:security@solana.com,discord:https://solana.com/discord", + policy: "https://github.com/solana-labs/solana-program-library/blob/master/SECURITY.md", + + // Optional Fields + preferred_languages: "en", + source_code: "https://github.com/solana-labs/solana-program-library/tree/master/stake-pool/program", + source_revision: "b7dd8fee93815b486fce98d3d43d1d0934980226", + source_release: "stake-pool-v1.0.0", + auditors: "https://github.com/solana-labs/security-audits#stake-pool" +} diff --git a/program/src/error.rs b/program/src/error.rs new file mode 100644 index 00000000..74a7885f --- /dev/null +++ b/program/src/error.rs @@ -0,0 +1,177 @@ +//! Error types + +use { + num_derive::FromPrimitive, + solana_program::{decode_error::DecodeError, program_error::ProgramError}, + thiserror::Error, +}; + +/// Errors that may be returned by the StakePool program. +#[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] +pub enum StakePoolError { + // 0. + /// The account cannot be initialized because it is already being used. + #[error("AlreadyInUse")] + AlreadyInUse, + /// The program address provided doesn't match the value generated by the + /// program. + #[error("InvalidProgramAddress")] + InvalidProgramAddress, + /// The stake pool state is invalid. + #[error("InvalidState")] + InvalidState, + /// The calculation failed. + #[error("CalculationFailure")] + CalculationFailure, + /// Stake pool fee > 1. + #[error("FeeTooHigh")] + FeeTooHigh, + + // 5. + /// Token account is associated with the wrong mint. + #[error("WrongAccountMint")] + WrongAccountMint, + /// Wrong pool manager account. + #[error("WrongManager")] + WrongManager, + /// Required signature is missing. + #[error("SignatureMissing")] + SignatureMissing, + /// Invalid validator stake list account. + #[error("InvalidValidatorStakeList")] + InvalidValidatorStakeList, + /// Invalid manager fee account. + #[error("InvalidFeeAccount")] + InvalidFeeAccount, + + // 10. + /// Specified pool mint account is wrong. + #[error("WrongPoolMint")] + WrongPoolMint, + /// Stake account is not in the state expected by the program. + #[error("WrongStakeStake")] + WrongStakeStake, + /// User stake is not active + #[error("UserStakeNotActive")] + UserStakeNotActive, + /// Stake account voting for this validator already exists in the pool. + #[error("ValidatorAlreadyAdded")] + ValidatorAlreadyAdded, + /// Stake account for this validator not found in the pool. + #[error("ValidatorNotFound")] + ValidatorNotFound, + + // 15. + /// Stake account address not properly derived from the validator address. + #[error("InvalidStakeAccountAddress")] + InvalidStakeAccountAddress, + /// Identify validator stake accounts with old balances and update them. + #[error("StakeListOutOfDate")] + StakeListOutOfDate, + /// First update old validator stake account balances and then pool stake + /// balance. + #[error("StakeListAndPoolOutOfDate")] + StakeListAndPoolOutOfDate, + /// Validator stake account is not found in the list storage. + #[error("UnknownValidatorStakeAccount")] + UnknownValidatorStakeAccount, + /// Wrong minting authority set for mint pool account + #[error("WrongMintingAuthority")] + WrongMintingAuthority, + + // 20. + /// The size of the given validator stake list does match the expected + /// amount + #[error("UnexpectedValidatorListAccountSize")] + UnexpectedValidatorListAccountSize, + /// Wrong pool staker account. + #[error("WrongStaker")] + WrongStaker, + /// Pool token supply is not zero on initialization + #[error("NonZeroPoolTokenSupply")] + NonZeroPoolTokenSupply, + /// The lamports in the validator stake account is not equal to the minimum + #[error("StakeLamportsNotEqualToMinimum")] + StakeLamportsNotEqualToMinimum, + /// The provided deposit stake account is not delegated to the preferred + /// deposit vote account + #[error("IncorrectDepositVoteAddress")] + IncorrectDepositVoteAddress, + + // 25. + /// The provided withdraw stake account is not the preferred deposit vote + /// account + #[error("IncorrectWithdrawVoteAddress")] + IncorrectWithdrawVoteAddress, + /// The mint has an invalid freeze authority + #[error("InvalidMintFreezeAuthority")] + InvalidMintFreezeAuthority, + /// Proposed fee increase exceeds stipulated ratio + #[error("FeeIncreaseTooHigh")] + FeeIncreaseTooHigh, + /// Not enough pool tokens provided to withdraw stake with one lamport + #[error("WithdrawalTooSmall")] + WithdrawalTooSmall, + /// Not enough lamports provided for deposit to result in one pool token + #[error("DepositTooSmall")] + DepositTooSmall, + + // 30. + /// Provided stake deposit authority does not match the program's + #[error("InvalidStakeDepositAuthority")] + InvalidStakeDepositAuthority, + /// Provided sol deposit authority does not match the program's + #[error("InvalidSolDepositAuthority")] + InvalidSolDepositAuthority, + /// Provided preferred validator is invalid + #[error("InvalidPreferredValidator")] + InvalidPreferredValidator, + /// Provided validator stake account already has a transient stake account + /// in use + #[error("TransientAccountInUse")] + TransientAccountInUse, + /// Provided sol withdraw authority does not match the program's + #[error("InvalidSolWithdrawAuthority")] + InvalidSolWithdrawAuthority, + + // 35. + /// Too much SOL withdrawn from the stake pool's reserve account + #[error("SolWithdrawalTooLarge")] + SolWithdrawalTooLarge, + /// Provided metadata account does not match metadata account derived for + /// pool mint + #[error("InvalidMetadataAccount")] + InvalidMetadataAccount, + /// The mint has an unsupported extension + #[error("UnsupportedMintExtension")] + UnsupportedMintExtension, + /// The fee account has an unsupported extension + #[error("UnsupportedFeeAccountExtension")] + UnsupportedFeeAccountExtension, + /// Instruction exceeds desired slippage limit + #[error("Instruction exceeds desired slippage limit")] + ExceededSlippage, + + // 40. + /// Provided mint does not have 9 decimals to match SOL + #[error("IncorrectMintDecimals")] + IncorrectMintDecimals, + /// Pool reserve does not have enough lamports to fund rent-exempt reserve + /// in split destination. Deposit more SOL in reserve, or pre-fund split + /// destination with the rent-exempt reserve for a stake account. + #[error("ReserveDepleted")] + ReserveDepleted, + /// Missing required sysvar account + #[error("Missing required sysvar account")] + MissingRequiredSysvar, +} +impl From for ProgramError { + fn from(e: StakePoolError) -> Self { + ProgramError::Custom(e as u32) + } +} +impl DecodeError for StakePoolError { + fn type_of() -> &'static str { + "Stake Pool Error" + } +} diff --git a/program/src/inline_mpl_token_metadata.rs b/program/src/inline_mpl_token_metadata.rs new file mode 100644 index 00000000..a5d0836b --- /dev/null +++ b/program/src/inline_mpl_token_metadata.rs @@ -0,0 +1,138 @@ +//! Inlined MPL metadata types to avoid a direct dependency on +//! `mpl-token-metadata' NOTE: this file is sym-linked in `spl-single-pool`, so +//! be careful with changes! + +solana_program::declare_id!("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"); + +pub(crate) mod instruction { + use { + super::state::DataV2, + borsh::{BorshDeserialize, BorshSerialize}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + }, + }; + + #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] + struct CreateMetadataAccountArgsV3 { + /// Note that unique metadatas are disabled for now. + pub data: DataV2, + /// Whether you want your metadata to be updateable in the future. + pub is_mutable: bool, + /// UNUSED If this is a collection parent NFT. + pub collection_details: Option, + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn create_metadata_accounts_v3( + program_id: Pubkey, + metadata_account: Pubkey, + mint: Pubkey, + mint_authority: Pubkey, + payer: Pubkey, + update_authority: Pubkey, + name: String, + symbol: String, + uri: String, + ) -> Instruction { + let mut data = vec![33]; // CreateMetadataAccountV3 + data.append( + &mut borsh::to_vec(&CreateMetadataAccountArgsV3 { + data: DataV2 { + name, + symbol, + uri, + seller_fee_basis_points: 0, + creators: None, + collection: None, + uses: None, + }, + is_mutable: true, + collection_details: None, + }) + .unwrap(), + ); + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(metadata_account, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new_readonly(mint_authority, true), + AccountMeta::new(payer, true), + AccountMeta::new_readonly(update_authority, true), + AccountMeta::new_readonly(solana_program::system_program::ID, false), + ], + data, + } + } + + #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] + struct UpdateMetadataAccountArgsV2 { + pub data: Option, + pub update_authority: Option, + pub primary_sale_happened: Option, + pub is_mutable: Option, + } + pub(crate) fn update_metadata_accounts_v2( + program_id: Pubkey, + metadata_account: Pubkey, + update_authority: Pubkey, + new_update_authority: Option, + metadata: Option, + primary_sale_happened: Option, + is_mutable: Option, + ) -> Instruction { + let mut data = vec![15]; // UpdateMetadataAccountV2 + data.append( + &mut borsh::to_vec(&UpdateMetadataAccountArgsV2 { + data: metadata, + update_authority: new_update_authority, + primary_sale_happened, + is_mutable, + }) + .unwrap(), + ); + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(metadata_account, false), + AccountMeta::new_readonly(update_authority, true), + ], + data, + } + } +} + +/// PDA creation helpers +pub mod pda { + use {super::ID, solana_program::pubkey::Pubkey}; + const PREFIX: &str = "metadata"; + /// Helper to find a metadata account address + pub fn find_metadata_account(mint: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[PREFIX.as_bytes(), ID.as_ref(), mint.as_ref()], &ID) + } +} + +pub(crate) mod state { + use borsh::{BorshDeserialize, BorshSerialize}; + #[repr(C)] + #[derive(BorshSerialize, BorshDeserialize, PartialEq, Eq, Debug, Clone)] + pub(crate) struct DataV2 { + /// The name of the asset + pub name: String, + /// The symbol for the asset + pub symbol: String, + /// URI pointing to JSON representing the asset + pub uri: String, + /// Royalty basis points that goes to creators in secondary sales + /// (0-10000) + pub seller_fee_basis_points: u16, + /// UNUSED Array of creators, optional + pub creators: Option, + /// UNUSED Collection + pub collection: Option, + /// UNUSED Uses + pub uses: Option, + } +} diff --git a/program/src/instruction.rs b/program/src/instruction.rs new file mode 100644 index 00000000..b5d6dc7a --- /dev/null +++ b/program/src/instruction.rs @@ -0,0 +1,2648 @@ +//! Instruction types + +// Remove the following `allow` when `Redelegate` is removed, required to avoid +// warnings from uses of deprecated types during trait derivations. +#![allow(deprecated)] +#![allow(clippy::too_many_arguments)] + +use { + crate::{ + find_deposit_authority_program_address, find_ephemeral_stake_program_address, + find_stake_program_address, find_transient_stake_program_address, + find_withdraw_authority_program_address, + inline_mpl_token_metadata::{self, pda::find_metadata_account}, + state::{Fee, FeeType, StakePool, ValidatorList, ValidatorStakeInfo}, + MAX_VALIDATORS_TO_UPDATE, + }, + borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + pubkey::Pubkey, + stake, + stake_history::Epoch, + system_program, sysvar, + }, + std::num::NonZeroU32, +}; + +/// Defines which validator vote account is set during the +/// `SetPreferredValidator` instruction +#[repr(C)] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)] +pub enum PreferredValidatorType { + /// Set preferred validator for deposits + Deposit, + /// Set preferred validator for withdraws + Withdraw, +} + +/// Defines which authority to update in the `SetFundingAuthority` +/// instruction +#[repr(C)] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)] +pub enum FundingType { + /// Sets the stake deposit authority + StakeDeposit, + /// Sets the SOL deposit authority + SolDeposit, + /// Sets the SOL withdraw authority + SolWithdraw, +} + +/// Instructions supported by the StakePool program. +#[repr(C)] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)] +pub enum StakePoolInstruction { + /// Initializes a new StakePool. + /// + /// 0. `[w]` New StakePool to create. + /// 1. `[s]` Manager + /// 2. `[]` Staker + /// 3. `[]` Stake pool withdraw authority + /// 4. `[w]` Uninitialized validator stake list storage account + /// 5. `[]` Reserve stake account must be initialized, have zero balance, + /// and staker / withdrawer authority set to pool withdraw authority. + /// 6. `[]` Pool token mint. Must have zero supply, owned by withdraw + /// authority. + /// 7. `[]` Pool account to deposit the generated fee for manager. + /// 8. `[]` Token program id + /// 9. `[]` (Optional) Deposit authority that must sign all deposits. + /// Defaults to the program address generated using + /// `find_deposit_authority_program_address`, making deposits + /// permissionless. + Initialize { + /// Fee assessed as percentage of perceived rewards + fee: Fee, + /// Fee charged per withdrawal as percentage of withdrawal + withdrawal_fee: Fee, + /// Fee charged per deposit as percentage of deposit + deposit_fee: Fee, + /// Percentage [0-100] of deposit_fee that goes to referrer + referral_fee: u8, + /// Maximum expected number of validators + max_validators: u32, + }, + + /// (Staker only) Adds stake account delegated to validator to the pool's + /// list of managed validators. + /// + /// The stake account will have the rent-exempt amount plus + /// `max( + /// crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation() + /// )`. + /// It is funded from the stake pool reserve. + /// + /// 0. `[w]` Stake pool + /// 1. `[s]` Staker + /// 2. `[w]` Reserve stake account + /// 3. `[]` Stake pool withdraw authority + /// 4. `[w]` Validator stake list storage account + /// 5. `[w]` Stake account to add to the pool + /// 6. `[]` Validator this stake account will be delegated to + /// 7. `[]` Rent sysvar + /// 8. `[]` Clock sysvar + /// 9. '[]' Stake history sysvar + /// 10. '[]' Stake config sysvar + /// 11. `[]` System program + /// 12. `[]` Stake program + /// + /// userdata: optional non-zero u32 seed used for generating the validator + /// stake address + AddValidatorToPool(u32), + + /// (Staker only) Removes validator from the pool, deactivating its stake + /// + /// Only succeeds if the validator stake account has the minimum of + /// `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. plus the + /// rent-exempt amount. + /// + /// 0. `[w]` Stake pool + /// 1. `[s]` Staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator stake list storage account + /// 4. `[w]` Stake account to remove from the pool + /// 5. `[w]` Transient stake account, to deactivate if necessary + /// 6. `[]` Sysvar clock + /// 7. `[]` Stake program id, + RemoveValidatorFromPool, + + /// NOTE: This instruction has been deprecated since version 0.7.0. Please + /// use `DecreaseValidatorStakeWithReserve` instead. + /// + /// (Staker only) Decrease active stake on a validator, eventually moving it + /// to the reserve + /// + /// Internally, this instruction splits a validator stake account into its + /// corresponding transient stake account and deactivates it. + /// + /// In order to rebalance the pool without taking custody, the staker needs + /// a way of reducing the stake on a stake account. This instruction splits + /// some amount of stake, up to the total activated stake, from the + /// canonical validator stake account, into its "transient" stake + /// account. + /// + /// The instruction only succeeds if the transient stake account does not + /// exist. The amount of lamports to move must be at least rent-exemption + /// plus `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Canonical stake account to split from + /// 5. `[w]` Transient stake account to receive split + /// 6. `[]` Clock sysvar + /// 7. `[]` Rent sysvar + /// 8. `[]` System program + /// 9. `[]` Stake program + DecreaseValidatorStake { + /// amount of lamports to split into the transient stake account + lamports: u64, + /// seed used to create transient stake account + transient_stake_seed: u64, + }, + + /// (Staker only) Increase stake on a validator from the reserve account + /// + /// Internally, this instruction splits reserve stake into a transient stake + /// account and delegate to the appropriate validator. + /// `UpdateValidatorListBalance` will do the work of merging once it's + /// ready. + /// + /// This instruction only succeeds if the transient stake account does not + /// exist. The minimum amount to move is rent-exemption plus + /// `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Stake pool reserve stake + /// 5. `[w]` Transient stake account + /// 6. `[]` Validator stake account + /// 7. `[]` Validator vote account to delegate to + /// 8. '[]' Clock sysvar + /// 9. '[]' Rent sysvar + /// 10. `[]` Stake History sysvar + /// 11. `[]` Stake Config sysvar + /// 12. `[]` System program + /// 13. `[]` Stake program + /// + /// userdata: amount of lamports to increase on the given validator. + /// + /// The actual amount split into the transient stake account is: + /// `lamports + stake_rent_exemption`. + /// + /// The rent-exemption of the stake account is withdrawn back to the + /// reserve after it is merged. + IncreaseValidatorStake { + /// amount of lamports to increase on the given validator + lamports: u64, + /// seed used to create transient stake account + transient_stake_seed: u64, + }, + + /// (Staker only) Set the preferred deposit or withdraw stake account for + /// the stake pool + /// + /// In order to avoid users abusing the stake pool as a free conversion + /// between SOL staked on different validators, the staker can force all + /// deposits and/or withdraws to go to one chosen account, or unset that + /// account. + /// + /// 0. `[w]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Validator list + /// + /// Fails if the validator is not part of the stake pool. + SetPreferredValidator { + /// Affected operation (deposit or withdraw) + validator_type: PreferredValidatorType, + /// Validator vote account that deposits or withdraws must go through, + /// unset with None + validator_vote_address: Option, + }, + + /// Updates balances of validator and transient stake accounts in the pool + /// + /// While going through the pairs of validator and transient stake + /// accounts, if the transient stake is inactive, it is merged into the + /// reserve stake account. If the transient stake is active and has + /// matching credits observed, it is merged into the canonical + /// validator stake account. In all other states, nothing is done, and + /// the balance is simply added to the canonical stake account balance. + /// + /// 0. `[]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[w]` Validator stake list storage account + /// 3. `[w]` Reserve stake account + /// 4. `[]` Sysvar clock + /// 5. `[]` Sysvar stake history + /// 6. `[]` Stake program + /// 7. ..7+2N ` [] N pairs of validator and transient stake accounts + UpdateValidatorListBalance { + /// Index to start updating on the validator list + start_index: u32, + /// If true, don't try merging transient stake accounts into the reserve + /// or validator stake account. Useful for testing or if a + /// particular stake account is in a bad state, but we still + /// want to update + no_merge: bool, + }, + + /// Updates total pool balance based on balances in the reserve and + /// validator list + /// + /// 0. `[w]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[w]` Validator stake list storage account + /// 3. `[]` Reserve stake account + /// 4. `[w]` Account to receive pool fee tokens + /// 5. `[w]` Pool mint account + /// 6. `[]` Pool token program + UpdateStakePoolBalance, + + /// Cleans up validator stake account entries marked as `ReadyForRemoval` + /// + /// 0. `[]` Stake pool + /// 1. `[w]` Validator stake list storage account + CleanupRemovedValidatorEntries, + + /// Deposit some stake into the pool. The output is a "pool" token + /// representing ownership into the pool. Inputs are converted to the + /// current ratio. + /// + /// 0. `[w]` Stake pool + /// 1. `[w]` Validator stake list storage account + /// 2. `[s]/[]` Stake pool deposit authority + /// 3. `[]` Stake pool withdraw authority + /// 4. `[w]` Stake account to join the pool (withdraw authority for the + /// stake account should be first set to the stake pool deposit + /// authority) + /// 5. `[w]` Validator stake account for the stake account to be merged + /// with + /// 6. `[w]` Reserve stake account, to withdraw rent exempt reserve + /// 7. `[w]` User account to receive pool tokens + /// 8. `[w]` Account to receive pool fee tokens + /// 9. `[w]` Account to receive a portion of pool fee tokens as referral + /// fees + /// 10. `[w]` Pool token mint account + /// 11. '[]' Sysvar clock account + /// 12. '[]' Sysvar stake history account + /// 13. `[]` Pool token program id, + /// 14. `[]` Stake program id, + DepositStake, + + /// Withdraw the token from the pool at the current ratio. + /// + /// Succeeds if the stake account has enough SOL to cover the desired + /// amount of pool tokens, and if the withdrawal keeps the total + /// staked amount above the minimum of rent-exempt amount + `max( + /// crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation() + /// )`. + /// + /// When allowing withdrawals, the order of priority goes: + /// + /// * preferred withdraw validator stake account (if set) + /// * validator stake accounts + /// * transient stake accounts + /// * reserve stake account OR totally remove validator stake accounts + /// + /// A user can freely withdraw from a validator stake account, and if they + /// are all at the minimum, then they can withdraw from transient stake + /// accounts, and if they are all at minimum, then they can withdraw from + /// the reserve or remove any validator from the pool. + /// + /// 0. `[w]` Stake pool + /// 1. `[w]` Validator stake list storage account + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator or reserve stake account to split + /// 4. `[w]` Uninitialized stake account to receive withdrawal + /// 5. `[]` User account to set as a new withdraw authority + /// 6. `[s]` User transfer authority, for pool token account + /// 7. `[w]` User account with pool tokens to burn from + /// 8. `[w]` Account to receive pool fee tokens + /// 9. `[w]` Pool token mint account + /// 10. `[]` Sysvar clock account (required) + /// 11. `[]` Pool token program id + /// 12. `[]` Stake program id, + /// + /// userdata: amount of pool tokens to withdraw + WithdrawStake(u64), + + /// (Manager only) Update manager + /// + /// 0. `[w]` StakePool + /// 1. `[s]` Manager + /// 2. `[s]` New manager + /// 3. `[]` New manager fee account + SetManager, + + /// (Manager only) Update fee + /// + /// 0. `[w]` StakePool + /// 1. `[s]` Manager + SetFee { + /// Type of fee to update and value to update it to + fee: FeeType, + }, + + /// (Manager or staker only) Update staker + /// + /// 0. `[w]` StakePool + /// 1. `[s]` Manager or current staker + /// 2. '[]` New staker pubkey + SetStaker, + + /// Deposit SOL directly into the pool's reserve account. The output is a + /// "pool" token representing ownership into the pool. Inputs are + /// converted to the current ratio. + /// + /// 0. `[w]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[w]` Reserve stake account, to deposit SOL + /// 3. `[s]` Account providing the lamports to be deposited into the pool + /// 4. `[w]` User account to receive pool tokens + /// 5. `[w]` Account to receive fee tokens + /// 6. `[w]` Account to receive a portion of fee as referral fees + /// 7. `[w]` Pool token mint account + /// 8. `[]` System program account + /// 9. `[]` Token program id + /// 10. `[s]` (Optional) Stake pool sol deposit authority. + DepositSol(u64), + + /// (Manager only) Update SOL deposit, stake deposit, or SOL withdrawal + /// authority. + /// + /// 0. `[w]` StakePool + /// 1. `[s]` Manager + /// 2. '[]` New authority pubkey or none + SetFundingAuthority(FundingType), + + /// Withdraw SOL directly from the pool's reserve account. Fails if the + /// reserve does not have enough SOL. + /// + /// 0. `[w]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[s]` User transfer authority, for pool token account + /// 3. `[w]` User account to burn pool tokens + /// 4. `[w]` Reserve stake account, to withdraw SOL + /// 5. `[w]` Account receiving the lamports from the reserve, must be a + /// system account + /// 6. `[w]` Account to receive pool fee tokens + /// 7. `[w]` Pool token mint account + /// 8. '[]' Clock sysvar + /// 9. '[]' Stake history sysvar + /// 10. `[]` Stake program account + /// 11. `[]` Token program id + /// 12. `[s]` (Optional) Stake pool sol withdraw authority + WithdrawSol(u64), + + /// Create token metadata for the stake-pool token in the + /// metaplex-token program + /// 0. `[]` Stake pool + /// 1. `[s]` Manager + /// 2. `[]` Stake pool withdraw authority + /// 3. `[]` Pool token mint account + /// 4. `[s, w]` Payer for creation of token metadata account + /// 5. `[w]` Token metadata account + /// 6. `[]` Metadata program id + /// 7. `[]` System program id + CreateTokenMetadata { + /// Token name + name: String, + /// Token symbol e.g. stkSOL + symbol: String, + /// URI of the uploaded metadata of the spl-token + uri: String, + }, + /// Update token metadata for the stake-pool token in the + /// metaplex-token program + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Manager + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Token metadata account + /// 4. `[]` Metadata program id + UpdateTokenMetadata { + /// Token name + name: String, + /// Token symbol e.g. stkSOL + symbol: String, + /// URI of the uploaded metadata of the spl-token + uri: String, + }, + + /// (Staker only) Increase stake on a validator again in an epoch. + /// + /// Works regardless if the transient stake account exists. + /// + /// Internally, this instruction splits reserve stake into an ephemeral + /// stake account, activates it, then merges or splits it into the + /// transient stake account delegated to the appropriate validator. + /// `UpdateValidatorListBalance` will do the work of merging once it's + /// ready. + /// + /// The minimum amount to move is rent-exemption plus + /// `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Stake pool reserve stake + /// 5. `[w]` Uninitialized ephemeral stake account to receive stake + /// 6. `[w]` Transient stake account + /// 7. `[]` Validator stake account + /// 8. `[]` Validator vote account to delegate to + /// 9. '[]' Clock sysvar + /// 10. `[]` Stake History sysvar + /// 11. `[]` Stake Config sysvar + /// 12. `[]` System program + /// 13. `[]` Stake program + /// + /// userdata: amount of lamports to increase on the given validator. + /// + /// The actual amount split into the transient stake account is: + /// `lamports + stake_rent_exemption`. + /// + /// The rent-exemption of the stake account is withdrawn back to the + /// reserve after it is merged. + IncreaseAdditionalValidatorStake { + /// amount of lamports to increase on the given validator + lamports: u64, + /// seed used to create transient stake account + transient_stake_seed: u64, + /// seed used to create ephemeral account. + ephemeral_stake_seed: u64, + }, + + /// (Staker only) Decrease active stake again from a validator, eventually + /// moving it to the reserve + /// + /// Works regardless if the transient stake account already exists. + /// + /// Internally, this instruction: + /// * withdraws rent-exempt reserve lamports from the reserve into the + /// ephemeral stake + /// * splits a validator stake account into an ephemeral stake account + /// * deactivates the ephemeral account + /// * merges or splits the ephemeral account into the transient stake + /// account delegated to the appropriate validator + /// + /// The amount of lamports to move must be at least + /// `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Reserve stake account, to fund rent exempt reserve + /// 5. `[w]` Canonical stake account to split from + /// 6. `[w]` Uninitialized ephemeral stake account to receive stake + /// 7. `[w]` Transient stake account + /// 8. `[]` Clock sysvar + /// 9. '[]' Stake history sysvar + /// 10. `[]` System program + /// 11. `[]` Stake program + DecreaseAdditionalValidatorStake { + /// amount of lamports to split into the transient stake account + lamports: u64, + /// seed used to create transient stake account + transient_stake_seed: u64, + /// seed used to create ephemeral account. + ephemeral_stake_seed: u64, + }, + + /// (Staker only) Decrease active stake on a validator, eventually moving it + /// to the reserve + /// + /// Internally, this instruction: + /// * withdraws enough lamports to make the transient account rent-exempt + /// * splits from a validator stake account into a transient stake account + /// * deactivates the transient stake account + /// + /// In order to rebalance the pool without taking custody, the staker needs + /// a way of reducing the stake on a stake account. This instruction splits + /// some amount of stake, up to the total activated stake, from the + /// canonical validator stake account, into its "transient" stake + /// account. + /// + /// The instruction only succeeds if the transient stake account does not + /// exist. The amount of lamports to move must be at least rent-exemption + /// plus `max(crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation())`. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Reserve stake account, to fund rent exempt reserve + /// 5. `[w]` Canonical stake account to split from + /// 6. `[w]` Transient stake account to receive split + /// 7. `[]` Clock sysvar + /// 8. '[]' Stake history sysvar + /// 9. `[]` System program + /// 10. `[]` Stake program + DecreaseValidatorStakeWithReserve { + /// amount of lamports to split into the transient stake account + lamports: u64, + /// seed used to create transient stake account + transient_stake_seed: u64, + }, + + /// (Staker only) Redelegate active stake on a validator, eventually moving + /// it to another + /// + /// Internally, this instruction splits a validator stake account into its + /// corresponding transient stake account, redelegates it to an ephemeral + /// stake account, then merges that stake into the destination transient + /// stake account. + /// + /// In order to rebalance the pool without taking custody, the staker needs + /// a way of reducing the stake on a stake account. This instruction splits + /// some amount of stake, up to the total activated stake, from the + /// canonical validator stake account, into its "transient" stake + /// account. + /// + /// The instruction only succeeds if the source transient stake account and + /// ephemeral stake account do not exist. + /// + /// The amount of lamports to move must be at least rent-exemption plus the + /// minimum delegation amount. Rent-exemption plus minimum delegation + /// is required for the destination ephemeral stake account. + /// + /// The rent-exemption for the source transient account comes from the stake + /// pool reserve, if needed. + /// + /// The amount that arrives at the destination validator in the end is + /// `redelegate_lamports - rent_exemption` if the destination transient + /// account does *not* exist, and `redelegate_lamports` if the destination + /// transient account already exists. The `rent_exemption` is not activated + /// when creating the destination transient stake account, but if it already + /// exists, then the full amount is delegated. + /// + /// 0. `[]` Stake pool + /// 1. `[s]` Stake pool staker + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator list + /// 4. `[w]` Reserve stake account, to withdraw rent exempt reserve + /// 5. `[w]` Source canonical stake account to split from + /// 6. `[w]` Source transient stake account to receive split and be + /// redelegated + /// 7. `[w]` Uninitialized ephemeral stake account to receive redelegation + /// 8. `[w]` Destination transient stake account to receive ephemeral stake + /// by merge + /// 9. `[]` Destination stake account to receive transient stake after + /// activation + /// 10. `[]` Destination validator vote account + /// 11. `[]` Clock sysvar + /// 12. `[]` Stake History sysvar + /// 13. `[]` Stake Config sysvar + /// 14. `[]` System program + /// 15. `[]` Stake program + #[deprecated( + since = "2.0.0", + note = "The stake redelegate instruction used in this will not be enabled." + )] + Redelegate { + /// Amount of lamports to redelegate + #[allow(dead_code)] // but it's not + lamports: u64, + /// Seed used to create source transient stake account + #[allow(dead_code)] // but it's not + source_transient_stake_seed: u64, + /// Seed used to create destination ephemeral account. + #[allow(dead_code)] // but it's not + ephemeral_stake_seed: u64, + /// Seed used to create destination transient stake account. If there is + /// already transient stake, this must match the current seed, otherwise + /// it can be anything + #[allow(dead_code)] // but it's not + destination_transient_stake_seed: u64, + }, + + /// Deposit some stake into the pool, with a specified slippage + /// constraint. The output is a "pool" token representing ownership + /// into the pool. Inputs are converted at the current ratio. + /// + /// 0. `[w]` Stake pool + /// 1. `[w]` Validator stake list storage account + /// 2. `[s]/[]` Stake pool deposit authority + /// 3. `[]` Stake pool withdraw authority + /// 4. `[w]` Stake account to join the pool (withdraw authority for the + /// stake account should be first set to the stake pool deposit + /// authority) + /// 5. `[w]` Validator stake account for the stake account to be merged + /// with + /// 6. `[w]` Reserve stake account, to withdraw rent exempt reserve + /// 7. `[w]` User account to receive pool tokens + /// 8. `[w]` Account to receive pool fee tokens + /// 9. `[w]` Account to receive a portion of pool fee tokens as referral + /// fees + /// 10. `[w]` Pool token mint account + /// 11. '[]' Sysvar clock account + /// 12. '[]' Sysvar stake history account + /// 13. `[]` Pool token program id, + /// 14. `[]` Stake program id, + DepositStakeWithSlippage { + /// Minimum amount of pool tokens that must be received + minimum_pool_tokens_out: u64, + }, + + /// Withdraw the token from the pool at the current ratio, specifying a + /// minimum expected output lamport amount. + /// + /// Succeeds if the stake account has enough SOL to cover the desired + /// amount of pool tokens, and if the withdrawal keeps the total + /// staked amount above the minimum of rent-exempt amount + `max( + /// crate::MINIMUM_ACTIVE_STAKE, + /// solana_program::stake::tools::get_minimum_delegation() + /// )`. + /// + /// 0. `[w]` Stake pool + /// 1. `[w]` Validator stake list storage account + /// 2. `[]` Stake pool withdraw authority + /// 3. `[w]` Validator or reserve stake account to split + /// 4. `[w]` Uninitialized stake account to receive withdrawal + /// 5. `[]` User account to set as a new withdraw authority + /// 6. `[s]` User transfer authority, for pool token account + /// 7. `[w]` User account with pool tokens to burn from + /// 8. `[w]` Account to receive pool fee tokens + /// 9. `[w]` Pool token mint account + /// 10. `[]` Sysvar clock account (required) + /// 11. `[]` Pool token program id + /// 12. `[]` Stake program id, + /// + /// userdata: amount of pool tokens to withdraw + WithdrawStakeWithSlippage { + /// Pool tokens to burn in exchange for lamports + pool_tokens_in: u64, + /// Minimum amount of lamports that must be received + minimum_lamports_out: u64, + }, + + /// Deposit SOL directly into the pool's reserve account, with a + /// specified slippage constraint. The output is a "pool" token + /// representing ownership into the pool. Inputs are converted at the + /// current ratio. + /// + /// 0. `[w]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[w]` Reserve stake account, to deposit SOL + /// 3. `[s]` Account providing the lamports to be deposited into the pool + /// 4. `[w]` User account to receive pool tokens + /// 5. `[w]` Account to receive fee tokens + /// 6. `[w]` Account to receive a portion of fee as referral fees + /// 7. `[w]` Pool token mint account + /// 8. `[]` System program account + /// 9. `[]` Token program id + /// 10. `[s]` (Optional) Stake pool sol deposit authority. + DepositSolWithSlippage { + /// Amount of lamports to deposit into the reserve + lamports_in: u64, + /// Minimum amount of pool tokens that must be received + minimum_pool_tokens_out: u64, + }, + + /// Withdraw SOL directly from the pool's reserve account. Fails if the + /// reserve does not have enough SOL or if the slippage constraint is not + /// met. + /// + /// 0. `[w]` Stake pool + /// 1. `[]` Stake pool withdraw authority + /// 2. `[s]` User transfer authority, for pool token account + /// 3. `[w]` User account to burn pool tokens + /// 4. `[w]` Reserve stake account, to withdraw SOL + /// 5. `[w]` Account receiving the lamports from the reserve, must be a + /// system account + /// 6. `[w]` Account to receive pool fee tokens + /// 7. `[w]` Pool token mint account + /// 8. '[]' Clock sysvar + /// 9. '[]' Stake history sysvar + /// 10. `[]` Stake program account + /// 11. `[]` Token program id + /// 12. `[s]` (Optional) Stake pool sol withdraw authority + WithdrawSolWithSlippage { + /// Pool tokens to burn in exchange for lamports + pool_tokens_in: u64, + /// Minimum amount of lamports that must be received + minimum_lamports_out: u64, + }, +} + +/// Creates an 'initialize' instruction. +pub fn initialize( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + pool_mint: &Pubkey, + manager_pool_account: &Pubkey, + token_program_id: &Pubkey, + deposit_authority: Option, + fee: Fee, + withdrawal_fee: Fee, + deposit_fee: Fee, + referral_fee: u8, + max_validators: u32, +) -> Instruction { + let init_data = StakePoolInstruction::Initialize { + fee, + withdrawal_fee, + deposit_fee, + referral_fee, + max_validators, + }; + let data = borsh::to_vec(&init_data).unwrap(); + let mut accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + AccountMeta::new_readonly(*staker, false), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new_readonly(*reserve_stake, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new(*manager_pool_account, false), + AccountMeta::new_readonly(*token_program_id, false), + ]; + if let Some(deposit_authority) = deposit_authority { + accounts.push(AccountMeta::new_readonly(deposit_authority, true)); + } + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates `AddValidatorToPool` instruction (add new validator stake account to +/// the pool) +pub fn add_validator_to_pool( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + reserve: &Pubkey, + stake_pool_withdraw: &Pubkey, + validator_list: &Pubkey, + stake: &Pubkey, + validator: &Pubkey, + seed: Option, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new(*reserve, false), + AccountMeta::new_readonly(*stake_pool_withdraw, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*stake, false), + AccountMeta::new_readonly(*validator, false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + let data = borsh::to_vec(&StakePoolInstruction::AddValidatorToPool( + seed.map(|s| s.get()).unwrap_or(0), + )) + .unwrap(); + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates `RemoveValidatorFromPool` instruction (remove validator stake +/// account from the pool) +pub fn remove_validator_from_pool( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw: &Pubkey, + validator_list: &Pubkey, + stake_account: &Pubkey, + transient_stake_account: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*stake_account, false), + AccountMeta::new(*transient_stake_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::RemoveValidatorFromPool).unwrap(), + } +} + +/// Creates `DecreaseValidatorStake` instruction (rebalance from validator +/// account to transient account) +#[deprecated( + since = "0.7.0", + note = "please use `decrease_validator_stake_with_reserve`" +)] +pub fn decrease_validator_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*validator_stake, false), + AccountMeta::new(*transient_stake, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DecreaseValidatorStake { + lamports, + transient_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `DecreaseAdditionalValidatorStake` instruction (rebalance from +/// validator account to transient account) +pub fn decrease_additional_validator_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + validator_stake: &Pubkey, + ephemeral_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*validator_stake, false), + AccountMeta::new(*ephemeral_stake, false), + AccountMeta::new(*transient_stake, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DecreaseAdditionalValidatorStake { + lamports, + transient_stake_seed, + ephemeral_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `DecreaseValidatorStakeWithReserve` instruction (rebalance from +/// validator account to transient account) +pub fn decrease_validator_stake_with_reserve( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*validator_stake, false), + AccountMeta::new(*transient_stake, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DecreaseValidatorStakeWithReserve { + lamports, + transient_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `IncreaseValidatorStake` instruction (rebalance from reserve account +/// to transient account) +pub fn increase_validator_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + transient_stake: &Pubkey, + validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + transient_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*transient_stake, false), + AccountMeta::new_readonly(*validator_stake, false), + AccountMeta::new_readonly(*validator, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::IncreaseValidatorStake { + lamports, + transient_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `IncreaseAdditionalValidatorStake` instruction (rebalance from +/// reserve account to transient account) +pub fn increase_additional_validator_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + ephemeral_stake: &Pubkey, + transient_stake: &Pubkey, + validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*ephemeral_stake, false), + AccountMeta::new(*transient_stake, false), + AccountMeta::new_readonly(*validator_stake, false), + AccountMeta::new_readonly(*validator, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::IncreaseAdditionalValidatorStake { + lamports, + transient_stake_seed, + ephemeral_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `Redelegate` instruction (rebalance from one validator account to +/// another) +#[deprecated( + since = "2.0.0", + note = "The stake redelegate instruction used in this will not be enabled." +)] +pub fn redelegate( + program_id: &Pubkey, + stake_pool: &Pubkey, + staker: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list: &Pubkey, + reserve_stake: &Pubkey, + source_validator_stake: &Pubkey, + source_transient_stake: &Pubkey, + ephemeral_stake: &Pubkey, + destination_transient_stake: &Pubkey, + destination_validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + source_transient_stake_seed: u64, + ephemeral_stake_seed: u64, + destination_transient_stake_seed: u64, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new(*source_validator_stake, false), + AccountMeta::new(*source_transient_stake, false), + AccountMeta::new(*ephemeral_stake, false), + AccountMeta::new(*destination_transient_stake, false), + AccountMeta::new_readonly(*destination_validator_stake, false), + AccountMeta::new_readonly(*validator, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::Redelegate { + lamports, + source_transient_stake_seed, + ephemeral_stake_seed, + destination_transient_stake_seed, + }) + .unwrap(), + } +} + +/// Creates `SetPreferredDepositValidator` instruction +pub fn set_preferred_validator( + program_id: &Pubkey, + stake_pool_address: &Pubkey, + staker: &Pubkey, + validator_list_address: &Pubkey, + validator_type: PreferredValidatorType, + validator_vote_address: Option, +) -> Instruction { + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*stake_pool_address, false), + AccountMeta::new_readonly(*staker, true), + AccountMeta::new_readonly(*validator_list_address, false), + ], + data: borsh::to_vec(&StakePoolInstruction::SetPreferredValidator { + validator_type, + validator_vote_address, + }) + .unwrap(), + } +} + +/// Create an `AddValidatorToPool` instruction given an existing stake pool and +/// vote account +pub fn add_validator_to_pool_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + seed: Option, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (stake_account_address, _) = + find_stake_program_address(program_id, vote_account_address, stake_pool_address, seed); + add_validator_to_pool( + program_id, + stake_pool_address, + &stake_pool.staker, + &stake_pool.reserve_stake, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_account_address, + vote_account_address, + seed, + ) +} + +/// Create an `RemoveValidatorFromPool` instruction given an existing stake pool +/// and vote account +pub fn remove_validator_from_pool_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + validator_stake_seed: Option, + transient_stake_seed: u64, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (stake_account_address, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + validator_stake_seed, + ); + let (transient_stake_account, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + transient_stake_seed, + ); + remove_validator_from_pool( + program_id, + stake_pool_address, + &stake_pool.staker, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_account_address, + &transient_stake_account, + ) +} + +/// Create an `IncreaseValidatorStake` instruction given an existing stake pool +/// and vote account +pub fn increase_validator_stake_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + validator_stake_seed: Option, + transient_stake_seed: u64, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (transient_stake_address, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + transient_stake_seed, + ); + let (validator_stake_address, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + validator_stake_seed, + ); + + increase_validator_stake( + program_id, + stake_pool_address, + &stake_pool.staker, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &transient_stake_address, + &validator_stake_address, + vote_account_address, + lamports, + transient_stake_seed, + ) +} + +/// Create an `IncreaseAdditionalValidatorStake` instruction given an existing +/// stake pool and vote account +pub fn increase_additional_validator_stake_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + validator_stake_seed: Option, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (ephemeral_stake_address, _) = + find_ephemeral_stake_program_address(program_id, stake_pool_address, ephemeral_stake_seed); + let (transient_stake_address, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + transient_stake_seed, + ); + let (validator_stake_address, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + validator_stake_seed, + ); + + increase_additional_validator_stake( + program_id, + stake_pool_address, + &stake_pool.staker, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &ephemeral_stake_address, + &transient_stake_address, + &validator_stake_address, + vote_account_address, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + ) +} + +/// Create a `DecreaseValidatorStake` instruction given an existing stake pool +/// and vote account +pub fn decrease_validator_stake_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + validator_stake_seed: Option, + transient_stake_seed: u64, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (validator_stake_address, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + validator_stake_seed, + ); + let (transient_stake_address, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + transient_stake_seed, + ); + decrease_validator_stake_with_reserve( + program_id, + stake_pool_address, + &stake_pool.staker, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &validator_stake_address, + &transient_stake_address, + lamports, + transient_stake_seed, + ) +} + +/// Create a `IncreaseAdditionalValidatorStake` instruction given an existing +/// stake pool, valiator list and vote account +pub fn increase_additional_validator_stake_with_list( + program_id: &Pubkey, + stake_pool: &StakePool, + validator_list: &ValidatorList, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + ephemeral_stake_seed: u64, +) -> Result { + let validator_info = validator_list + .find(vote_account_address) + .ok_or(ProgramError::InvalidInstructionData)?; + let transient_stake_seed = u64::from(validator_info.transient_seed_suffix); + let validator_stake_seed = NonZeroU32::new(validator_info.validator_seed_suffix.into()); + Ok(increase_additional_validator_stake_with_vote( + program_id, + stake_pool, + stake_pool_address, + vote_account_address, + lamports, + validator_stake_seed, + transient_stake_seed, + ephemeral_stake_seed, + )) +} + +/// Create a `DecreaseAdditionalValidatorStake` instruction given an existing +/// stake pool, valiator list and vote account +pub fn decrease_additional_validator_stake_with_list( + program_id: &Pubkey, + stake_pool: &StakePool, + validator_list: &ValidatorList, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + ephemeral_stake_seed: u64, +) -> Result { + let validator_info = validator_list + .find(vote_account_address) + .ok_or(ProgramError::InvalidInstructionData)?; + let transient_stake_seed = u64::from(validator_info.transient_seed_suffix); + let validator_stake_seed = NonZeroU32::new(validator_info.validator_seed_suffix.into()); + Ok(decrease_additional_validator_stake_with_vote( + program_id, + stake_pool, + stake_pool_address, + vote_account_address, + lamports, + validator_stake_seed, + transient_stake_seed, + ephemeral_stake_seed, + )) +} + +/// Create a `DecreaseAdditionalValidatorStake` instruction given an existing +/// stake pool and vote account +pub fn decrease_additional_validator_stake_with_vote( + program_id: &Pubkey, + stake_pool: &StakePool, + stake_pool_address: &Pubkey, + vote_account_address: &Pubkey, + lamports: u64, + validator_stake_seed: Option, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, +) -> Instruction { + let pool_withdraw_authority = + find_withdraw_authority_program_address(program_id, stake_pool_address).0; + let (validator_stake_address, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + validator_stake_seed, + ); + let (ephemeral_stake_address, _) = + find_ephemeral_stake_program_address(program_id, stake_pool_address, ephemeral_stake_seed); + let (transient_stake_address, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool_address, + transient_stake_seed, + ); + decrease_additional_validator_stake( + program_id, + stake_pool_address, + &stake_pool.staker, + &pool_withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &validator_stake_address, + &ephemeral_stake_address, + &transient_stake_address, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + ) +} + +/// Creates `UpdateValidatorListBalance` instruction (update validator stake +/// account balances) +#[deprecated( + since = "1.1.0", + note = "please use `update_validator_list_balance_chunk`" +)] +pub fn update_validator_list_balance( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list_address: &Pubkey, + reserve_stake: &Pubkey, + validator_list: &ValidatorList, + validator_vote_accounts: &[Pubkey], + start_index: u32, + no_merge: bool, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list_address, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + accounts.append( + &mut validator_vote_accounts + .iter() + .flat_map(|vote_account_address| { + let validator_stake_info = validator_list.find(vote_account_address); + if let Some(validator_stake_info) = validator_stake_info { + let (validator_stake_account, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + ); + let (transient_stake_account, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool, + validator_stake_info.transient_seed_suffix.into(), + ); + vec![ + AccountMeta::new(validator_stake_account, false), + AccountMeta::new(transient_stake_account, false), + ] + } else { + vec![] + } + }) + .collect::>(), + ); + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::UpdateValidatorListBalance { + start_index, + no_merge, + }) + .unwrap(), + } +} + +/// Creates an `UpdateValidatorListBalance` instruction (update validator stake +/// account balances) to update `validator_list[start_index..start_index + +/// len]`. +/// +/// Returns `Err(ProgramError::InvalidInstructionData)` if: +/// - `start_index..start_index + len` is out of bounds for +/// `validator_list.validators` +pub fn update_validator_list_balance_chunk( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list_address: &Pubkey, + reserve_stake: &Pubkey, + validator_list: &ValidatorList, + len: usize, + start_index: usize, + no_merge: bool, +) -> Result { + let mut accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*validator_list_address, false), + AccountMeta::new(*reserve_stake, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + let validator_list_subslice = validator_list + .validators + .get(start_index..start_index.saturating_add(len)) + .ok_or(ProgramError::InvalidInstructionData)?; + accounts.extend(validator_list_subslice.iter().flat_map( + |ValidatorStakeInfo { + vote_account_address, + validator_seed_suffix, + transient_seed_suffix, + .. + }| { + let (validator_stake_account, _) = find_stake_program_address( + program_id, + vote_account_address, + stake_pool, + NonZeroU32::new((*validator_seed_suffix).into()), + ); + let (transient_stake_account, _) = find_transient_stake_program_address( + program_id, + vote_account_address, + stake_pool, + (*transient_seed_suffix).into(), + ); + [ + AccountMeta::new(validator_stake_account, false), + AccountMeta::new(transient_stake_account, false), + ] + }, + )); + Ok(Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::UpdateValidatorListBalance { + start_index: start_index.try_into().unwrap(), + no_merge, + }) + .unwrap(), + }) +} + +/// Creates `UpdateValidatorListBalance` instruction (update validator stake +/// account balances) +/// +/// Returns `None` if all validators in the given chunk has already been updated +/// for this epoch, returns the required instruction otherwise. +pub fn update_stale_validator_list_balance_chunk( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + validator_list_address: &Pubkey, + reserve_stake: &Pubkey, + validator_list: &ValidatorList, + len: usize, + start_index: usize, + no_merge: bool, + current_epoch: Epoch, +) -> Result, ProgramError> { + let validator_list_subslice = validator_list + .validators + .get(start_index..start_index.saturating_add(len)) + .ok_or(ProgramError::InvalidInstructionData)?; + if validator_list_subslice.iter().all(|info| { + let last_update_epoch: u64 = info.last_update_epoch.into(); + last_update_epoch >= current_epoch + }) { + return Ok(None); + } + update_validator_list_balance_chunk( + program_id, + stake_pool, + stake_pool_withdraw_authority, + validator_list_address, + reserve_stake, + validator_list, + len, + start_index, + no_merge, + ) + .map(Some) +} + +/// Creates `UpdateStakePoolBalance` instruction (pool balance from the stake +/// account list balances) +pub fn update_stake_pool_balance( + program_id: &Pubkey, + stake_pool: &Pubkey, + withdraw_authority: &Pubkey, + validator_list_storage: &Pubkey, + reserve_stake: &Pubkey, + manager_fee_account: &Pubkey, + stake_pool_mint: &Pubkey, + token_program_id: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*withdraw_authority, false), + AccountMeta::new(*validator_list_storage, false), + AccountMeta::new_readonly(*reserve_stake, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*stake_pool_mint, false), + AccountMeta::new_readonly(*token_program_id, false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::UpdateStakePoolBalance).unwrap(), + } +} + +/// Creates `CleanupRemovedValidatorEntries` instruction (removes entries from +/// the validator list) +pub fn cleanup_removed_validator_entries( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new(*validator_list_storage, false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::CleanupRemovedValidatorEntries).unwrap(), + } +} + +/// Creates all `UpdateValidatorListBalance` and `UpdateStakePoolBalance` +/// instructions for fully updating a stake pool each epoch +pub fn update_stake_pool( + program_id: &Pubkey, + stake_pool: &StakePool, + validator_list: &ValidatorList, + stake_pool_address: &Pubkey, + no_merge: bool, +) -> (Vec, Vec) { + let (withdraw_authority, _) = + find_withdraw_authority_program_address(program_id, stake_pool_address); + + let update_list_instructions = validator_list + .validators + .chunks(MAX_VALIDATORS_TO_UPDATE) + .enumerate() + .map(|(i, chunk)| { + // unwrap-safety: chunk len and offset are derived + update_validator_list_balance_chunk( + program_id, + stake_pool_address, + &withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + validator_list, + chunk.len(), + i.saturating_mul(MAX_VALIDATORS_TO_UPDATE), + no_merge, + ) + .unwrap() + }) + .collect(); + + let final_instructions = vec![ + update_stake_pool_balance( + program_id, + stake_pool_address, + &withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &stake_pool.manager_fee_account, + &stake_pool.pool_mint, + &stake_pool.token_program_id, + ), + cleanup_removed_validator_entries( + program_id, + stake_pool_address, + &stake_pool.validator_list, + ), + ]; + (update_list_instructions, final_instructions) +} + +/// Creates the `UpdateValidatorListBalance` instructions only for validators on +/// `validator_list` that have not been updated for this epoch, and the +/// `UpdateStakePoolBalance` instruction for fully updating the stake pool. +/// +/// Basically same as [`update_stake_pool`], but skips validators that are +/// already updated for this epoch +pub fn update_stale_stake_pool( + program_id: &Pubkey, + stake_pool: &StakePool, + validator_list: &ValidatorList, + stake_pool_address: &Pubkey, + no_merge: bool, + current_epoch: Epoch, +) -> (Vec, Vec) { + let (withdraw_authority, _) = + find_withdraw_authority_program_address(program_id, stake_pool_address); + + let update_list_instructions = validator_list + .validators + .chunks(MAX_VALIDATORS_TO_UPDATE) + .enumerate() + .filter_map(|(i, chunk)| { + // unwrap-safety: chunk len and offset are derived + update_stale_validator_list_balance_chunk( + program_id, + stake_pool_address, + &withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + validator_list, + chunk.len(), + i.saturating_mul(MAX_VALIDATORS_TO_UPDATE), + no_merge, + current_epoch, + ) + .unwrap() + }) + .collect(); + + let final_instructions = vec![ + update_stake_pool_balance( + program_id, + stake_pool_address, + &withdraw_authority, + &stake_pool.validator_list, + &stake_pool.reserve_stake, + &stake_pool.manager_fee_account, + &stake_pool.pool_mint, + &stake_pool.token_program_id, + ), + cleanup_removed_validator_entries( + program_id, + stake_pool_address, + &stake_pool.validator_list, + ), + ]; + (update_list_instructions, final_instructions) +} + +fn deposit_stake_internal( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_deposit_authority: Option<&Pubkey>, + stake_pool_withdraw_authority: &Pubkey, + deposit_stake_address: &Pubkey, + deposit_stake_withdraw_authority: &Pubkey, + validator_stake_account: &Pubkey, + reserve_stake_account: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + minimum_pool_tokens_out: Option, +) -> Vec { + let mut instructions = vec![]; + let mut accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new(*validator_list_storage, false), + ]; + if let Some(stake_pool_deposit_authority) = stake_pool_deposit_authority { + accounts.push(AccountMeta::new_readonly( + *stake_pool_deposit_authority, + true, + )); + instructions.extend_from_slice(&[ + stake::instruction::authorize( + deposit_stake_address, + deposit_stake_withdraw_authority, + stake_pool_deposit_authority, + stake::state::StakeAuthorize::Staker, + None, + ), + stake::instruction::authorize( + deposit_stake_address, + deposit_stake_withdraw_authority, + stake_pool_deposit_authority, + stake::state::StakeAuthorize::Withdrawer, + None, + ), + ]); + } else { + let stake_pool_deposit_authority = + find_deposit_authority_program_address(program_id, stake_pool).0; + accounts.push(AccountMeta::new_readonly( + stake_pool_deposit_authority, + false, + )); + instructions.extend_from_slice(&[ + stake::instruction::authorize( + deposit_stake_address, + deposit_stake_withdraw_authority, + &stake_pool_deposit_authority, + stake::state::StakeAuthorize::Staker, + None, + ), + stake::instruction::authorize( + deposit_stake_address, + deposit_stake_withdraw_authority, + &stake_pool_deposit_authority, + stake::state::StakeAuthorize::Withdrawer, + None, + ), + ]); + }; + + accounts.extend_from_slice(&[ + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*deposit_stake_address, false), + AccountMeta::new(*validator_stake_account, false), + AccountMeta::new(*reserve_stake_account, false), + AccountMeta::new(*pool_tokens_to, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*referrer_pool_tokens_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(*token_program_id, false), + AccountMeta::new_readonly(stake::program::id(), false), + ]); + instructions.push( + if let Some(minimum_pool_tokens_out) = minimum_pool_tokens_out { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DepositStakeWithSlippage { + minimum_pool_tokens_out, + }) + .unwrap(), + } + } else { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DepositStake).unwrap(), + } + }, + ); + instructions +} + +/// Creates instructions required to deposit into a stake pool, given a stake +/// account owned by the user. +pub fn deposit_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + deposit_stake_address: &Pubkey, + deposit_stake_withdraw_authority: &Pubkey, + validator_stake_account: &Pubkey, + reserve_stake_account: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, +) -> Vec { + deposit_stake_internal( + program_id, + stake_pool, + validator_list_storage, + None, + stake_pool_withdraw_authority, + deposit_stake_address, + deposit_stake_withdraw_authority, + validator_stake_account, + reserve_stake_account, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + None, + ) +} + +/// Creates instructions to deposit into a stake pool with slippage +pub fn deposit_stake_with_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + deposit_stake_address: &Pubkey, + deposit_stake_withdraw_authority: &Pubkey, + validator_stake_account: &Pubkey, + reserve_stake_account: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + minimum_pool_tokens_out: u64, +) -> Vec { + deposit_stake_internal( + program_id, + stake_pool, + validator_list_storage, + None, + stake_pool_withdraw_authority, + deposit_stake_address, + deposit_stake_withdraw_authority, + validator_stake_account, + reserve_stake_account, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + Some(minimum_pool_tokens_out), + ) +} + +/// Creates instructions required to deposit into a stake pool, given a stake +/// account owned by the user. The difference with `deposit()` is that a deposit +/// authority must sign this instruction, which is required for private pools. +pub fn deposit_stake_with_authority( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_deposit_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + deposit_stake_address: &Pubkey, + deposit_stake_withdraw_authority: &Pubkey, + validator_stake_account: &Pubkey, + reserve_stake_account: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, +) -> Vec { + deposit_stake_internal( + program_id, + stake_pool, + validator_list_storage, + Some(stake_pool_deposit_authority), + stake_pool_withdraw_authority, + deposit_stake_address, + deposit_stake_withdraw_authority, + validator_stake_account, + reserve_stake_account, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + None, + ) +} + +/// Creates instructions required to deposit into a stake pool with slippage, +/// given a stake account owned by the user. The difference with `deposit()` is +/// that a deposit authority must sign this instruction, which is required for +/// private pools. +pub fn deposit_stake_with_authority_and_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_deposit_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + deposit_stake_address: &Pubkey, + deposit_stake_withdraw_authority: &Pubkey, + validator_stake_account: &Pubkey, + reserve_stake_account: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + minimum_pool_tokens_out: u64, +) -> Vec { + deposit_stake_internal( + program_id, + stake_pool, + validator_list_storage, + Some(stake_pool_deposit_authority), + stake_pool_withdraw_authority, + deposit_stake_address, + deposit_stake_withdraw_authority, + validator_stake_account, + reserve_stake_account, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + Some(minimum_pool_tokens_out), + ) +} + +/// Creates instructions required to deposit SOL directly into a stake pool. +fn deposit_sol_internal( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_from: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + sol_deposit_authority: Option<&Pubkey>, + lamports_in: u64, + minimum_pool_tokens_out: Option, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new(*reserve_stake_account, false), + AccountMeta::new(*lamports_from, true), + AccountMeta::new(*pool_tokens_to, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*referrer_pool_tokens_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(*token_program_id, false), + ]; + if let Some(sol_deposit_authority) = sol_deposit_authority { + accounts.push(AccountMeta::new_readonly(*sol_deposit_authority, true)); + } + if let Some(minimum_pool_tokens_out) = minimum_pool_tokens_out { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DepositSolWithSlippage { + lamports_in, + minimum_pool_tokens_out, + }) + .unwrap(), + } + } else { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::DepositSol(lamports_in)).unwrap(), + } + } +} + +/// Creates instruction to deposit SOL directly into a stake pool. +pub fn deposit_sol( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_from: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + lamports_in: u64, +) -> Instruction { + deposit_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + reserve_stake_account, + lamports_from, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + None, + lamports_in, + None, + ) +} + +/// Creates instruction to deposit SOL directly into a stake pool with slippage +/// constraint. +pub fn deposit_sol_with_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_from: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + lamports_in: u64, + minimum_pool_tokens_out: u64, +) -> Instruction { + deposit_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + reserve_stake_account, + lamports_from, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + None, + lamports_in, + Some(minimum_pool_tokens_out), + ) +} + +/// Creates instruction required to deposit SOL directly into a stake pool. +/// The difference with `deposit_sol()` is that a deposit +/// authority must sign this instruction. +pub fn deposit_sol_with_authority( + program_id: &Pubkey, + stake_pool: &Pubkey, + sol_deposit_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_from: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + lamports_in: u64, +) -> Instruction { + deposit_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + reserve_stake_account, + lamports_from, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + Some(sol_deposit_authority), + lamports_in, + None, + ) +} + +/// Creates instruction to deposit SOL directly into a stake pool with slippage +/// constraint. +pub fn deposit_sol_with_authority_and_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + sol_deposit_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_from: &Pubkey, + pool_tokens_to: &Pubkey, + manager_fee_account: &Pubkey, + referrer_pool_tokens_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + lamports_in: u64, + minimum_pool_tokens_out: u64, +) -> Instruction { + deposit_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + reserve_stake_account, + lamports_from, + pool_tokens_to, + manager_fee_account, + referrer_pool_tokens_account, + pool_mint, + token_program_id, + Some(sol_deposit_authority), + lamports_in, + Some(minimum_pool_tokens_out), + ) +} + +fn withdraw_stake_internal( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_withdraw: &Pubkey, + stake_to_split: &Pubkey, + stake_to_receive: &Pubkey, + user_stake_authority: &Pubkey, + user_transfer_authority: &Pubkey, + user_pool_token_account: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, + minimum_lamports_out: Option, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new(*validator_list_storage, false), + AccountMeta::new_readonly(*stake_pool_withdraw, false), + AccountMeta::new(*stake_to_split, false), + AccountMeta::new(*stake_to_receive, false), + AccountMeta::new_readonly(*user_stake_authority, false), + AccountMeta::new_readonly(*user_transfer_authority, true), + AccountMeta::new(*user_pool_token_account, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(*token_program_id, false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + if let Some(minimum_lamports_out) = minimum_lamports_out { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::WithdrawStakeWithSlippage { + pool_tokens_in, + minimum_lamports_out, + }) + .unwrap(), + } + } else { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::WithdrawStake(pool_tokens_in)).unwrap(), + } + } +} + +/// Creates a 'WithdrawStake' instruction. +pub fn withdraw_stake( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_withdraw: &Pubkey, + stake_to_split: &Pubkey, + stake_to_receive: &Pubkey, + user_stake_authority: &Pubkey, + user_transfer_authority: &Pubkey, + user_pool_token_account: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, +) -> Instruction { + withdraw_stake_internal( + program_id, + stake_pool, + validator_list_storage, + stake_pool_withdraw, + stake_to_split, + stake_to_receive, + user_stake_authority, + user_transfer_authority, + user_pool_token_account, + manager_fee_account, + pool_mint, + token_program_id, + pool_tokens_in, + None, + ) +} + +/// Creates a 'WithdrawStakeWithSlippage' instruction. +pub fn withdraw_stake_with_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + validator_list_storage: &Pubkey, + stake_pool_withdraw: &Pubkey, + stake_to_split: &Pubkey, + stake_to_receive: &Pubkey, + user_stake_authority: &Pubkey, + user_transfer_authority: &Pubkey, + user_pool_token_account: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, + minimum_lamports_out: u64, +) -> Instruction { + withdraw_stake_internal( + program_id, + stake_pool, + validator_list_storage, + stake_pool_withdraw, + stake_to_split, + stake_to_receive, + user_stake_authority, + user_transfer_authority, + user_pool_token_account, + manager_fee_account, + pool_mint, + token_program_id, + pool_tokens_in, + Some(minimum_lamports_out), + ) +} + +fn withdraw_sol_internal( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + user_transfer_authority: &Pubkey, + pool_tokens_from: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_to: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + sol_withdraw_authority: Option<&Pubkey>, + pool_tokens_in: u64, + minimum_lamports_out: Option, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*stake_pool_withdraw_authority, false), + AccountMeta::new_readonly(*user_transfer_authority, true), + AccountMeta::new(*pool_tokens_from, false), + AccountMeta::new(*reserve_stake_account, false), + AccountMeta::new(*lamports_to, false), + AccountMeta::new(*manager_fee_account, false), + AccountMeta::new(*pool_mint, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + AccountMeta::new_readonly(*token_program_id, false), + ]; + if let Some(sol_withdraw_authority) = sol_withdraw_authority { + accounts.push(AccountMeta::new_readonly(*sol_withdraw_authority, true)); + } + if let Some(minimum_lamports_out) = minimum_lamports_out { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::WithdrawSolWithSlippage { + pool_tokens_in, + minimum_lamports_out, + }) + .unwrap(), + } + } else { + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::WithdrawSol(pool_tokens_in)).unwrap(), + } + } +} + +/// Creates instruction required to withdraw SOL directly from a stake pool. +pub fn withdraw_sol( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + user_transfer_authority: &Pubkey, + pool_tokens_from: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_to: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, +) -> Instruction { + withdraw_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + user_transfer_authority, + pool_tokens_from, + reserve_stake_account, + lamports_to, + manager_fee_account, + pool_mint, + token_program_id, + None, + pool_tokens_in, + None, + ) +} + +/// Creates instruction required to withdraw SOL directly from a stake pool with +/// slippage constraints. +pub fn withdraw_sol_with_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + user_transfer_authority: &Pubkey, + pool_tokens_from: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_to: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, + minimum_lamports_out: u64, +) -> Instruction { + withdraw_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + user_transfer_authority, + pool_tokens_from, + reserve_stake_account, + lamports_to, + manager_fee_account, + pool_mint, + token_program_id, + None, + pool_tokens_in, + Some(minimum_lamports_out), + ) +} + +/// Creates instruction required to withdraw SOL directly from a stake pool. +/// The difference with `withdraw_sol()` is that the sol withdraw authority +/// must sign this instruction. +pub fn withdraw_sol_with_authority( + program_id: &Pubkey, + stake_pool: &Pubkey, + sol_withdraw_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + user_transfer_authority: &Pubkey, + pool_tokens_from: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_to: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, +) -> Instruction { + withdraw_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + user_transfer_authority, + pool_tokens_from, + reserve_stake_account, + lamports_to, + manager_fee_account, + pool_mint, + token_program_id, + Some(sol_withdraw_authority), + pool_tokens_in, + None, + ) +} + +/// Creates instruction required to withdraw SOL directly from a stake pool with +/// a slippage constraint. +/// The difference with `withdraw_sol()` is that the sol withdraw authority +/// must sign this instruction. +pub fn withdraw_sol_with_authority_and_slippage( + program_id: &Pubkey, + stake_pool: &Pubkey, + sol_withdraw_authority: &Pubkey, + stake_pool_withdraw_authority: &Pubkey, + user_transfer_authority: &Pubkey, + pool_tokens_from: &Pubkey, + reserve_stake_account: &Pubkey, + lamports_to: &Pubkey, + manager_fee_account: &Pubkey, + pool_mint: &Pubkey, + token_program_id: &Pubkey, + pool_tokens_in: u64, + minimum_lamports_out: u64, +) -> Instruction { + withdraw_sol_internal( + program_id, + stake_pool, + stake_pool_withdraw_authority, + user_transfer_authority, + pool_tokens_from, + reserve_stake_account, + lamports_to, + manager_fee_account, + pool_mint, + token_program_id, + Some(sol_withdraw_authority), + pool_tokens_in, + Some(minimum_lamports_out), + ) +} + +/// Creates a 'set manager' instruction. +pub fn set_manager( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + new_manager: &Pubkey, + new_fee_receiver: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + AccountMeta::new_readonly(*new_manager, true), + AccountMeta::new_readonly(*new_fee_receiver, false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::SetManager).unwrap(), + } +} + +/// Creates a 'set fee' instruction. +pub fn set_fee( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + fee: FeeType, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::SetFee { fee }).unwrap(), + } +} + +/// Creates a 'set staker' instruction. +pub fn set_staker( + program_id: &Pubkey, + stake_pool: &Pubkey, + set_staker_authority: &Pubkey, + new_staker: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*set_staker_authority, true), + AccountMeta::new_readonly(*new_staker, false), + ]; + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::SetStaker).unwrap(), + } +} + +/// Creates a 'SetFundingAuthority' instruction. +pub fn set_funding_authority( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + new_sol_deposit_authority: Option<&Pubkey>, + funding_type: FundingType, +) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + ]; + if let Some(auth) = new_sol_deposit_authority { + accounts.push(AccountMeta::new_readonly(*auth, false)) + } + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::SetFundingAuthority(funding_type)).unwrap(), + } +} + +/// Creates an instruction to update metadata in the mpl token metadata program +/// account for the pool token +pub fn update_token_metadata( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + pool_mint: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> Instruction { + let (stake_pool_withdraw_authority, _) = + find_withdraw_authority_program_address(program_id, stake_pool); + let (token_metadata, _) = find_metadata_account(pool_mint); + + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + AccountMeta::new_readonly(stake_pool_withdraw_authority, false), + AccountMeta::new(token_metadata, false), + AccountMeta::new_readonly(inline_mpl_token_metadata::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::UpdateTokenMetadata { name, symbol, uri }) + .unwrap(), + } +} + +/// Creates an instruction to create metadata using the mpl token metadata +/// program for the pool token +pub fn create_token_metadata( + program_id: &Pubkey, + stake_pool: &Pubkey, + manager: &Pubkey, + pool_mint: &Pubkey, + payer: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> Instruction { + let (stake_pool_withdraw_authority, _) = + find_withdraw_authority_program_address(program_id, stake_pool); + let (token_metadata, _) = find_metadata_account(pool_mint); + + let accounts = vec![ + AccountMeta::new_readonly(*stake_pool, false), + AccountMeta::new_readonly(*manager, true), + AccountMeta::new_readonly(stake_pool_withdraw_authority, false), + AccountMeta::new_readonly(*pool_mint, false), + AccountMeta::new(*payer, true), + AccountMeta::new(token_metadata, false), + AccountMeta::new_readonly(inline_mpl_token_metadata::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data: borsh::to_vec(&StakePoolInstruction::CreateTokenMetadata { name, symbol, uri }) + .unwrap(), + } +} diff --git a/program/src/lib.rs b/program/src/lib.rs new file mode 100644 index 00000000..fe48880b --- /dev/null +++ b/program/src/lib.rs @@ -0,0 +1,175 @@ +#![deny(missing_docs)] + +//! A program for creating and managing pools of stake + +pub mod big_vec; +pub mod error; +pub mod inline_mpl_token_metadata; +pub mod instruction; +pub mod processor; +pub mod state; + +#[cfg(not(feature = "no-entrypoint"))] +pub mod entrypoint; + +// Export current sdk types for downstream users building with a different sdk +// version +pub use solana_program; +use { + crate::state::Fee, + solana_program::{pubkey::Pubkey, stake::state::Meta}, + std::num::NonZeroU32, +}; + +/// Seed for deposit authority seed +const AUTHORITY_DEPOSIT: &[u8] = b"deposit"; + +/// Seed for withdraw authority seed +const AUTHORITY_WITHDRAW: &[u8] = b"withdraw"; + +/// Seed for transient stake account +const TRANSIENT_STAKE_SEED_PREFIX: &[u8] = b"transient"; + +/// Seed for ephemeral stake account +const EPHEMERAL_STAKE_SEED_PREFIX: &[u8] = b"ephemeral"; + +/// Minimum amount of staked lamports required in a validator stake account to +/// allow for merges without a mismatch on credits observed +pub const MINIMUM_ACTIVE_STAKE: u64 = 1_000_000; + +/// Minimum amount of lamports in the reserve +pub const MINIMUM_RESERVE_LAMPORTS: u64 = 0; + +/// Maximum amount of validator stake accounts to update per +/// `UpdateValidatorListBalance` instruction, based on compute limits +pub const MAX_VALIDATORS_TO_UPDATE: usize = 5; + +/// Maximum factor by which a withdrawal fee can be increased per epoch +/// protecting stakers from malicious users. +/// If current fee is 0, WITHDRAWAL_BASELINE_FEE is used as the baseline +pub const MAX_WITHDRAWAL_FEE_INCREASE: Fee = Fee { + numerator: 3, + denominator: 2, +}; +/// Drop-in baseline fee when evaluating withdrawal fee increases when fee is 0 +pub const WITHDRAWAL_BASELINE_FEE: Fee = Fee { + numerator: 1, + denominator: 1000, +}; + +/// The maximum number of transient stake accounts respecting +/// transaction account limits. +pub const MAX_TRANSIENT_STAKE_ACCOUNTS: usize = 10; + +/// Get the stake amount under consideration when calculating pool token +/// conversions +#[inline] +pub fn minimum_stake_lamports(meta: &Meta, stake_program_minimum_delegation: u64) -> u64 { + meta.rent_exempt_reserve + .saturating_add(minimum_delegation(stake_program_minimum_delegation)) +} + +/// Get the minimum delegation required by a stake account in a stake pool +#[inline] +pub fn minimum_delegation(stake_program_minimum_delegation: u64) -> u64 { + std::cmp::max(stake_program_minimum_delegation, MINIMUM_ACTIVE_STAKE) +} + +/// Get the stake amount under consideration when calculating pool token +/// conversions +#[inline] +pub fn minimum_reserve_lamports(meta: &Meta) -> u64 { + meta.rent_exempt_reserve + .saturating_add(MINIMUM_RESERVE_LAMPORTS) +} + +/// Generates the deposit authority program address for the stake pool +pub fn find_deposit_authority_program_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[stake_pool_address.as_ref(), AUTHORITY_DEPOSIT], + program_id, + ) +} + +/// Generates the withdraw authority program address for the stake pool +pub fn find_withdraw_authority_program_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[stake_pool_address.as_ref(), AUTHORITY_WITHDRAW], + program_id, + ) +} + +/// Generates the stake program address for a validator's vote account +pub fn find_stake_program_address( + program_id: &Pubkey, + vote_account_address: &Pubkey, + stake_pool_address: &Pubkey, + seed: Option, +) -> (Pubkey, u8) { + let seed = seed.map(|s| s.get().to_le_bytes()); + Pubkey::find_program_address( + &[ + vote_account_address.as_ref(), + stake_pool_address.as_ref(), + seed.as_ref().map(|s| s.as_slice()).unwrap_or(&[]), + ], + program_id, + ) +} + +/// Generates the stake program address for a validator's vote account +pub fn find_transient_stake_program_address( + program_id: &Pubkey, + vote_account_address: &Pubkey, + stake_pool_address: &Pubkey, + seed: u64, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + TRANSIENT_STAKE_SEED_PREFIX, + vote_account_address.as_ref(), + stake_pool_address.as_ref(), + &seed.to_le_bytes(), + ], + program_id, + ) +} + +/// Generates the ephemeral program address for stake pool redelegation +pub fn find_ephemeral_stake_program_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, + seed: u64, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + EPHEMERAL_STAKE_SEED_PREFIX, + stake_pool_address.as_ref(), + &seed.to_le_bytes(), + ], + program_id, + ) +} + +solana_program::declare_id!("SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy"); + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn validator_stake_account_derivation() { + let vote = Pubkey::new_unique(); + let stake_pool = Pubkey::new_unique(); + let function_derived = find_stake_program_address(&id(), &vote, &stake_pool, None); + let hand_derived = + Pubkey::find_program_address(&[vote.as_ref(), stake_pool.as_ref()], &id()); + assert_eq!(function_derived, hand_derived); + } +} diff --git a/program/src/processor.rs b/program/src/processor.rs new file mode 100644 index 00000000..24887f26 --- /dev/null +++ b/program/src/processor.rs @@ -0,0 +1,3715 @@ +//! Program state processor + +use { + crate::{ + error::StakePoolError, + find_deposit_authority_program_address, + inline_mpl_token_metadata::{ + self, + instruction::{create_metadata_accounts_v3, update_metadata_accounts_v2}, + pda::find_metadata_account, + state::DataV2, + }, + instruction::{FundingType, PreferredValidatorType, StakePoolInstruction}, + minimum_delegation, minimum_reserve_lamports, minimum_stake_lamports, + state::{ + is_extension_supported_for_mint, AccountType, Fee, FeeType, FutureEpoch, StakePool, + StakeStatus, StakeWithdrawSource, ValidatorList, ValidatorListHeader, + ValidatorStakeInfo, + }, + AUTHORITY_DEPOSIT, AUTHORITY_WITHDRAW, EPHEMERAL_STAKE_SEED_PREFIX, + TRANSIENT_STAKE_SEED_PREFIX, + }, + borsh::BorshDeserialize, + num_traits::FromPrimitive, + solana_program::{ + account_info::{next_account_info, AccountInfo}, + borsh1::try_from_slice_unchecked, + clock::{Clock, Epoch}, + decode_error::DecodeError, + entrypoint::ProgramResult, + msg, + program::{invoke, invoke_signed}, + program_error::{PrintProgramError, ProgramError}, + pubkey::Pubkey, + rent::Rent, + stake, system_instruction, system_program, + sysvar::Sysvar, + }, + spl_token_2022::{ + check_spl_token_program_account, + extension::{BaseStateWithExtensions, StateWithExtensions}, + native_mint, + state::Mint, + }, + std::num::NonZeroU32, +}; + +/// Deserialize the stake state from AccountInfo +fn get_stake_state( + stake_account_info: &AccountInfo, +) -> Result<(stake::state::Meta, stake::state::Stake), ProgramError> { + let stake_state = + try_from_slice_unchecked::(&stake_account_info.data.borrow())?; + match stake_state { + stake::state::StakeStateV2::Stake(meta, stake, _) => Ok((meta, stake)), + _ => Err(StakePoolError::WrongStakeStake.into()), + } +} + +/// Check validity of vote address for a particular stake account +fn check_validator_stake_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, + stake_account_address: &Pubkey, + vote_address: &Pubkey, + seed: Option, +) -> Result<(), ProgramError> { + // Check stake account address validity + let (validator_stake_address, _) = + crate::find_stake_program_address(program_id, vote_address, stake_pool_address, seed); + if validator_stake_address != *stake_account_address { + msg!( + "Incorrect stake account address for vote {}, expected {}, received {}", + vote_address, + validator_stake_address, + stake_account_address + ); + Err(StakePoolError::InvalidStakeAccountAddress.into()) + } else { + Ok(()) + } +} + +/// Check validity of vote address for a particular stake account +fn check_transient_stake_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, + stake_account_address: &Pubkey, + vote_address: &Pubkey, + seed: u64, +) -> Result { + // Check stake account address validity + let (transient_stake_address, bump_seed) = crate::find_transient_stake_program_address( + program_id, + vote_address, + stake_pool_address, + seed, + ); + if transient_stake_address != *stake_account_address { + Err(StakePoolError::InvalidStakeAccountAddress.into()) + } else { + Ok(bump_seed) + } +} + +/// Check address validity for an ephemeral stake account +fn check_ephemeral_stake_address( + program_id: &Pubkey, + stake_pool_address: &Pubkey, + stake_account_address: &Pubkey, + seed: u64, +) -> Result { + // Check stake account address validity + let (ephemeral_stake_address, bump_seed) = + crate::find_ephemeral_stake_program_address(program_id, stake_pool_address, seed); + if ephemeral_stake_address != *stake_account_address { + Err(StakePoolError::InvalidStakeAccountAddress.into()) + } else { + Ok(bump_seed) + } +} + +/// Check mpl metadata account address for the pool mint +fn check_mpl_metadata_account_address( + metadata_address: &Pubkey, + pool_mint: &Pubkey, +) -> Result<(), ProgramError> { + let (metadata_account_pubkey, _) = find_metadata_account(pool_mint); + if metadata_account_pubkey != *metadata_address { + Err(StakePoolError::InvalidMetadataAccount.into()) + } else { + Ok(()) + } +} + +/// Check system program address +fn check_system_program(program_id: &Pubkey) -> Result<(), ProgramError> { + if *program_id != system_program::id() { + msg!( + "Expected system program {}, received {}", + system_program::id(), + program_id + ); + Err(ProgramError::IncorrectProgramId) + } else { + Ok(()) + } +} + +/// Check stake program address +fn check_stake_program(program_id: &Pubkey) -> Result<(), ProgramError> { + if *program_id != stake::program::id() { + msg!( + "Expected stake program {}, received {}", + stake::program::id(), + program_id + ); + Err(ProgramError::IncorrectProgramId) + } else { + Ok(()) + } +} + +/// Check mpl metadata program +fn check_mpl_metadata_program(program_id: &Pubkey) -> Result<(), ProgramError> { + if *program_id != inline_mpl_token_metadata::id() { + msg!( + "Expected mpl metadata program {}, received {}", + inline_mpl_token_metadata::id(), + program_id + ); + Err(ProgramError::IncorrectProgramId) + } else { + Ok(()) + } +} + +/// Check account owner is the given program +fn check_account_owner( + account_info: &AccountInfo, + program_id: &Pubkey, +) -> Result<(), ProgramError> { + if *program_id != *account_info.owner { + msg!( + "Expected account to be owned by program {}, received {}", + program_id, + account_info.owner + ); + Err(ProgramError::IncorrectProgramId) + } else { + Ok(()) + } +} + +/// Checks if a stake account can be managed by the pool +fn stake_is_usable_by_pool( + meta: &stake::state::Meta, + expected_authority: &Pubkey, + expected_lockup: &stake::state::Lockup, +) -> bool { + meta.authorized.staker == *expected_authority + && meta.authorized.withdrawer == *expected_authority + && meta.lockup == *expected_lockup +} + +/// Checks if a stake account is active, without taking into account cooldowns +fn stake_is_inactive_without_history(stake: &stake::state::Stake, epoch: Epoch) -> bool { + stake.delegation.deactivation_epoch < epoch + || (stake.delegation.activation_epoch == epoch + && stake.delegation.deactivation_epoch == epoch) +} + +/// Roughly checks if a stake account is deactivating +fn check_if_stake_deactivating( + account_info: &AccountInfo, + vote_account_address: &Pubkey, + epoch: Epoch, +) -> Result<(), ProgramError> { + let (_, stake) = get_stake_state(account_info)?; + if stake.delegation.deactivation_epoch != epoch { + msg!( + "Existing stake {} delegated to {} not deactivated in epoch {}", + account_info.key, + vote_account_address, + epoch, + ); + Err(StakePoolError::WrongStakeStake.into()) + } else { + Ok(()) + } +} + +/// Roughly checks if a stake account is activating +fn check_if_stake_activating( + account_info: &AccountInfo, + vote_account_address: &Pubkey, + epoch: Epoch, +) -> Result<(), ProgramError> { + let (_, stake) = get_stake_state(account_info)?; + if stake.delegation.deactivation_epoch != Epoch::MAX + || stake.delegation.activation_epoch != epoch + { + msg!( + "Existing stake {} delegated to {} not activated in epoch {}", + account_info.key, + vote_account_address, + epoch, + ); + Err(StakePoolError::WrongStakeStake.into()) + } else { + Ok(()) + } +} + +/// Check that the stake state is correct: usable by the pool and delegated to +/// the expected validator +fn check_stake_state( + stake_account_info: &AccountInfo, + withdraw_authority: &Pubkey, + vote_account_address: &Pubkey, + lockup: &stake::state::Lockup, +) -> Result<(), ProgramError> { + let (meta, stake) = get_stake_state(stake_account_info)?; + if !stake_is_usable_by_pool(&meta, withdraw_authority, lockup) { + msg!( + "Validator stake for {} not usable by pool, must be owned by withdraw authority", + vote_account_address + ); + return Err(StakePoolError::WrongStakeStake.into()); + } + if stake.delegation.voter_pubkey != *vote_account_address { + msg!( + "Validator stake {} not delegated to {}", + stake_account_info.key, + vote_account_address + ); + return Err(StakePoolError::WrongStakeStake.into()); + } + Ok(()) +} + +/// Checks if a validator stake account is valid, which means that it's usable +/// by the pool and delegated to the expected validator. These conditions can be +/// violated if a validator was force destaked during a cluster restart. +fn check_validator_stake_account( + stake_account_info: &AccountInfo, + program_id: &Pubkey, + stake_pool: &Pubkey, + withdraw_authority: &Pubkey, + vote_account_address: &Pubkey, + seed: u32, + lockup: &stake::state::Lockup, +) -> Result<(), ProgramError> { + check_account_owner(stake_account_info, &stake::program::id())?; + check_validator_stake_address( + program_id, + stake_pool, + stake_account_info.key, + vote_account_address, + NonZeroU32::new(seed), + )?; + check_stake_state( + stake_account_info, + withdraw_authority, + vote_account_address, + lockup, + )?; + Ok(()) +} + +/// Create a stake account on a PDA without transferring lamports +fn create_stake_account( + stake_account_info: AccountInfo<'_>, + stake_account_signer_seeds: &[&[u8]], + stake_space: usize, +) -> Result<(), ProgramError> { + invoke_signed( + &system_instruction::allocate(stake_account_info.key, stake_space as u64), + &[stake_account_info.clone()], + &[stake_account_signer_seeds], + )?; + invoke_signed( + &system_instruction::assign(stake_account_info.key, &stake::program::id()), + &[stake_account_info], + &[stake_account_signer_seeds], + ) +} + +/// Program state handler. +pub struct Processor {} +impl Processor { + /// Issue a delegate_stake instruction. + #[allow(clippy::too_many_arguments)] + fn stake_delegate<'a>( + stake_info: AccountInfo<'a>, + vote_account_info: AccountInfo<'a>, + clock_info: AccountInfo<'a>, + stake_history_info: AccountInfo<'a>, + stake_config_info: AccountInfo<'a>, + authority_info: AccountInfo<'a>, + stake_pool: &Pubkey, + authority_type: &[u8], + bump_seed: u8, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let ix = stake::instruction::delegate_stake( + stake_info.key, + authority_info.key, + vote_account_info.key, + ); + + invoke_signed( + &ix, + &[ + stake_info, + vote_account_info, + clock_info, + stake_history_info, + stake_config_info, + authority_info, + ], + signers, + ) + } + + /// Issue a stake_deactivate instruction. + fn stake_deactivate<'a>( + stake_info: AccountInfo<'a>, + clock_info: AccountInfo<'a>, + authority_info: AccountInfo<'a>, + stake_pool: &Pubkey, + authority_type: &[u8], + bump_seed: u8, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let ix = stake::instruction::deactivate_stake(stake_info.key, authority_info.key); + + invoke_signed(&ix, &[stake_info, clock_info, authority_info], signers) + } + + /// Issue a stake_split instruction. + fn stake_split<'a>( + stake_pool: &Pubkey, + stake_account: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_type: &[u8], + bump_seed: u8, + amount: u64, + split_stake: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let split_instruction = + stake::instruction::split(stake_account.key, authority.key, amount, split_stake.key); + + invoke_signed( + split_instruction + .last() + .ok_or(ProgramError::InvalidInstructionData)?, + &[stake_account, split_stake, authority], + signers, + ) + } + + /// Issue a stake_merge instruction. + #[allow(clippy::too_many_arguments)] + fn stake_merge<'a>( + stake_pool: &Pubkey, + source_account: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_type: &[u8], + bump_seed: u8, + destination_account: AccountInfo<'a>, + clock: AccountInfo<'a>, + stake_history: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let merge_instruction = + stake::instruction::merge(destination_account.key, source_account.key, authority.key); + + invoke_signed( + &merge_instruction[0], + &[ + destination_account, + source_account, + clock, + stake_history, + authority, + ], + signers, + ) + } + + /// Issue stake::instruction::authorize instructions to update both + /// authorities + fn stake_authorize<'a>( + stake_account: AccountInfo<'a>, + stake_authority: AccountInfo<'a>, + new_stake_authority: &Pubkey, + clock: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authorize_instruction = stake::instruction::authorize( + stake_account.key, + stake_authority.key, + new_stake_authority, + stake::state::StakeAuthorize::Staker, + None, + ); + + invoke( + &authorize_instruction, + &[ + stake_account.clone(), + clock.clone(), + stake_authority.clone(), + ], + )?; + + let authorize_instruction = stake::instruction::authorize( + stake_account.key, + stake_authority.key, + new_stake_authority, + stake::state::StakeAuthorize::Withdrawer, + None, + ); + + invoke( + &authorize_instruction, + &[stake_account, clock, stake_authority], + ) + } + + /// Issue stake::instruction::authorize instructions to update both + /// authorities + #[allow(clippy::too_many_arguments)] + fn stake_authorize_signed<'a>( + stake_pool: &Pubkey, + stake_account: AccountInfo<'a>, + stake_authority: AccountInfo<'a>, + authority_type: &[u8], + bump_seed: u8, + new_stake_authority: &Pubkey, + clock: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let authorize_instruction = stake::instruction::authorize( + stake_account.key, + stake_authority.key, + new_stake_authority, + stake::state::StakeAuthorize::Staker, + None, + ); + + invoke_signed( + &authorize_instruction, + &[ + stake_account.clone(), + clock.clone(), + stake_authority.clone(), + ], + signers, + )?; + + let authorize_instruction = stake::instruction::authorize( + stake_account.key, + stake_authority.key, + new_stake_authority, + stake::state::StakeAuthorize::Withdrawer, + None, + ); + invoke_signed( + &authorize_instruction, + &[stake_account, clock, stake_authority], + signers, + ) + } + + /// Issue stake::instruction::withdraw instruction to move additional + /// lamports + #[allow(clippy::too_many_arguments)] + fn stake_withdraw<'a>( + stake_pool: &Pubkey, + source_account: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_type: &[u8], + bump_seed: u8, + destination_account: AccountInfo<'a>, + clock: AccountInfo<'a>, + stake_history: AccountInfo<'a>, + lamports: u64, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + let custodian_pubkey = None; + + let withdraw_instruction = stake::instruction::withdraw( + source_account.key, + authority.key, + destination_account.key, + lamports, + custodian_pubkey, + ); + + invoke_signed( + &withdraw_instruction, + &[ + source_account, + destination_account, + clock, + stake_history, + authority, + ], + signers, + ) + } + + /// Issue a spl_token `Burn` instruction. + #[allow(clippy::too_many_arguments)] + fn token_burn<'a>( + token_program: AccountInfo<'a>, + burn_account: AccountInfo<'a>, + mint: AccountInfo<'a>, + authority: AccountInfo<'a>, + amount: u64, + ) -> Result<(), ProgramError> { + let ix = spl_token_2022::instruction::burn( + token_program.key, + burn_account.key, + mint.key, + authority.key, + &[], + amount, + )?; + + invoke(&ix, &[burn_account, mint, authority]) + } + + /// Issue a spl_token `MintTo` instruction. + #[allow(clippy::too_many_arguments)] + fn token_mint_to<'a>( + stake_pool: &Pubkey, + token_program: AccountInfo<'a>, + mint: AccountInfo<'a>, + destination: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_type: &[u8], + bump_seed: u8, + amount: u64, + ) -> Result<(), ProgramError> { + let authority_signature_seeds = [stake_pool.as_ref(), authority_type, &[bump_seed]]; + let signers = &[&authority_signature_seeds[..]]; + + let ix = spl_token_2022::instruction::mint_to( + token_program.key, + mint.key, + destination.key, + authority.key, + &[], + amount, + )?; + + invoke_signed(&ix, &[mint, destination, authority], signers) + } + + /// Issue a spl_token `Transfer` instruction. + #[allow(clippy::too_many_arguments)] + fn token_transfer<'a>( + token_program: AccountInfo<'a>, + source: AccountInfo<'a>, + mint: AccountInfo<'a>, + destination: AccountInfo<'a>, + authority: AccountInfo<'a>, + amount: u64, + decimals: u8, + ) -> Result<(), ProgramError> { + let ix = spl_token_2022::instruction::transfer_checked( + token_program.key, + source.key, + mint.key, + destination.key, + authority.key, + &[], + amount, + decimals, + )?; + invoke(&ix, &[source, mint, destination, authority]) + } + + fn sol_transfer<'a>( + source: AccountInfo<'a>, + destination: AccountInfo<'a>, + amount: u64, + ) -> Result<(), ProgramError> { + let ix = solana_program::system_instruction::transfer(source.key, destination.key, amount); + invoke(&ix, &[source, destination]) + } + + /// Processes `Initialize` instruction. + #[inline(never)] // needed due to stack size violation + fn process_initialize( + program_id: &Pubkey, + accounts: &[AccountInfo], + epoch_fee: Fee, + withdrawal_fee: Fee, + deposit_fee: Fee, + referral_fee: u8, + max_validators: u32, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + + let rent = Rent::get()?; + + if !manager_info.is_signer { + msg!("Manager did not sign initialization"); + return Err(StakePoolError::SignatureMissing.into()); + } + + if stake_pool_info.key == validator_list_info.key { + msg!("Cannot use same account for stake pool and validator list"); + return Err(StakePoolError::AlreadyInUse.into()); + } + + // This check is unnecessary since the runtime will check the ownership, + // but provides clarity that the parameter is in fact checked. + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_uninitialized() { + msg!("Provided stake pool already in use"); + return Err(StakePoolError::AlreadyInUse.into()); + } + + // This check is unnecessary since the runtime will check the ownership, + // but provides clarity that the parameter is in fact checked. + check_account_owner(validator_list_info, program_id)?; + let mut validator_list = + try_from_slice_unchecked::(&validator_list_info.data.borrow())?; + if !validator_list.header.is_uninitialized() { + msg!("Provided validator list already in use"); + return Err(StakePoolError::AlreadyInUse.into()); + } + + let data_length = validator_list_info.data_len(); + let expected_max_validators = ValidatorList::calculate_max_validators(data_length); + if expected_max_validators != max_validators as usize || max_validators == 0 { + msg!( + "Incorrect validator list size provided, expected {}, provided {}", + expected_max_validators, + max_validators + ); + return Err(StakePoolError::UnexpectedValidatorListAccountSize.into()); + } + validator_list.header.account_type = AccountType::ValidatorList; + validator_list.header.max_validators = max_validators; + validator_list.validators.clear(); + + if !rent.is_exempt(stake_pool_info.lamports(), stake_pool_info.data_len()) { + msg!("Stake pool not rent-exempt"); + return Err(ProgramError::AccountNotRentExempt); + } + + if !rent.is_exempt( + validator_list_info.lamports(), + validator_list_info.data_len(), + ) { + msg!("Validator stake list not rent-exempt"); + return Err(ProgramError::AccountNotRentExempt); + } + + // Numerator should be smaller than or equal to denominator (fee <= 1) + if epoch_fee.numerator > epoch_fee.denominator + || withdrawal_fee.numerator > withdrawal_fee.denominator + || deposit_fee.numerator > deposit_fee.denominator + || referral_fee > 100u8 + { + return Err(StakePoolError::FeeTooHigh.into()); + } + + check_spl_token_program_account(token_program_info.key)?; + + if pool_mint_info.owner != token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + + stake_pool.token_program_id = *token_program_info.key; + stake_pool.pool_mint = *pool_mint_info.key; + + let (stake_deposit_authority, sol_deposit_authority) = + match next_account_info(account_info_iter) { + Ok(deposit_authority_info) => ( + *deposit_authority_info.key, + Some(*deposit_authority_info.key), + ), + Err(_) => ( + find_deposit_authority_program_address(program_id, stake_pool_info.key).0, + None, + ), + }; + let (withdraw_authority_key, stake_withdraw_bump_seed) = + crate::find_withdraw_authority_program_address(program_id, stake_pool_info.key); + if withdraw_authority_key != *withdraw_authority_info.key { + msg!( + "Incorrect withdraw authority provided, expected {}, received {}", + withdraw_authority_key, + withdraw_authority_info.key + ); + return Err(StakePoolError::InvalidProgramAddress.into()); + } + + { + let pool_mint_data = pool_mint_info.try_borrow_data()?; + let pool_mint = StateWithExtensions::::unpack(&pool_mint_data)?; + + if pool_mint.base.supply != 0 { + return Err(StakePoolError::NonZeroPoolTokenSupply.into()); + } + + if pool_mint.base.decimals != native_mint::DECIMALS { + return Err(StakePoolError::IncorrectMintDecimals.into()); + } + + if !pool_mint + .base + .mint_authority + .contains(&withdraw_authority_key) + { + return Err(StakePoolError::WrongMintingAuthority.into()); + } + + if pool_mint.base.freeze_authority.is_some() { + return Err(StakePoolError::InvalidMintFreezeAuthority.into()); + } + + let extensions = pool_mint.get_extension_types()?; + if extensions + .iter() + .any(|x| !is_extension_supported_for_mint(x)) + { + return Err(StakePoolError::UnsupportedMintExtension.into()); + } + } + stake_pool.check_manager_fee_info(manager_fee_info)?; + + if *reserve_stake_info.owner != stake::program::id() { + msg!("Reserve stake account not owned by stake program"); + return Err(ProgramError::IncorrectProgramId); + } + let stake_state = try_from_slice_unchecked::( + &reserve_stake_info.data.borrow(), + )?; + let total_lamports = if let stake::state::StakeStateV2::Initialized(meta) = stake_state { + if meta.lockup != stake::state::Lockup::default() { + msg!("Reserve stake account has some lockup"); + return Err(StakePoolError::WrongStakeStake.into()); + } + + if meta.authorized.staker != withdraw_authority_key { + msg!( + "Reserve stake account has incorrect staker {}, should be {}", + meta.authorized.staker, + withdraw_authority_key + ); + return Err(StakePoolError::WrongStakeStake.into()); + } + + if meta.authorized.withdrawer != withdraw_authority_key { + msg!( + "Reserve stake account has incorrect withdrawer {}, should be {}", + meta.authorized.staker, + withdraw_authority_key + ); + return Err(StakePoolError::WrongStakeStake.into()); + } + reserve_stake_info + .lamports() + .checked_sub(minimum_reserve_lamports(&meta)) + .ok_or(StakePoolError::CalculationFailure)? + } else { + msg!("Reserve stake account not in intialized state"); + return Err(StakePoolError::WrongStakeStake.into()); + }; + + if total_lamports > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_withdraw_bump_seed, + total_lamports, + )?; + } + + borsh::to_writer( + &mut validator_list_info.data.borrow_mut()[..], + &validator_list, + )?; + + stake_pool.account_type = AccountType::StakePool; + stake_pool.manager = *manager_info.key; + stake_pool.staker = *staker_info.key; + stake_pool.stake_deposit_authority = stake_deposit_authority; + stake_pool.stake_withdraw_bump_seed = stake_withdraw_bump_seed; + stake_pool.validator_list = *validator_list_info.key; + stake_pool.reserve_stake = *reserve_stake_info.key; + stake_pool.manager_fee_account = *manager_fee_info.key; + stake_pool.total_lamports = total_lamports; + stake_pool.pool_token_supply = total_lamports; + stake_pool.last_update_epoch = Clock::get()?.epoch; + stake_pool.lockup = stake::state::Lockup::default(); + stake_pool.epoch_fee = epoch_fee; + stake_pool.next_epoch_fee = FutureEpoch::None; + stake_pool.preferred_deposit_validator_vote_address = None; + stake_pool.preferred_withdraw_validator_vote_address = None; + stake_pool.stake_deposit_fee = deposit_fee; + stake_pool.stake_withdrawal_fee = withdrawal_fee; + stake_pool.next_stake_withdrawal_fee = FutureEpoch::None; + stake_pool.stake_referral_fee = referral_fee; + stake_pool.sol_deposit_authority = sol_deposit_authority; + stake_pool.sol_deposit_fee = deposit_fee; + stake_pool.sol_referral_fee = referral_fee; + stake_pool.sol_withdraw_authority = None; + stake_pool.sol_withdrawal_fee = withdrawal_fee; + stake_pool.next_sol_withdrawal_fee = FutureEpoch::None; + stake_pool.last_epoch_pool_token_supply = 0; + stake_pool.last_epoch_total_lamports = 0; + + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool) + .map_err(|e| e.into()) + } + + /// Processes `AddValidatorToPool` instruction. + #[inline(never)] // needed due to stack size violation + fn process_add_validator_to_pool( + program_id: &Pubkey, + accounts: &[AccountInfo], + raw_validator_seed: u32, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let stake_info = next_account_info(account_info_iter)?; + let validator_vote_info = next_account_info(account_info_iter)?; + let rent_info = next_account_info(account_info_iter)?; + let rent = &Rent::from_account_info(rent_info)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let stake_history_info = next_account_info(account_info_iter)?; + let stake_config_info = next_account_info(account_info_iter)?; + let system_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_system_program(system_program_info.key)?; + check_stake_program(stake_program_info.key)?; + + check_account_owner(stake_pool_info, program_id)?; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + + stake_pool.check_staker(staker_info)?; + stake_pool.check_reserve_stake(reserve_stake_info)?; + stake_pool.check_validator_list(validator_list_info)?; + + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + if header.max_validators == validator_list.len() { + return Err(ProgramError::AccountDataTooSmall); + } + let maybe_validator_stake_info = validator_list.find::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, validator_vote_info.key) + }); + if maybe_validator_stake_info.is_some() { + return Err(StakePoolError::ValidatorAlreadyAdded.into()); + } + + let validator_seed = NonZeroU32::new(raw_validator_seed); + let (stake_address, bump_seed) = crate::find_stake_program_address( + program_id, + validator_vote_info.key, + stake_pool_info.key, + validator_seed, + ); + if stake_address != *stake_info.key { + return Err(StakePoolError::InvalidStakeAccountAddress.into()); + } + + let validator_seed_bytes = validator_seed.map(|s| s.get().to_le_bytes()); + let stake_account_signer_seeds: &[&[_]] = &[ + validator_vote_info.key.as_ref(), + stake_pool_info.key.as_ref(), + validator_seed_bytes + .as_ref() + .map(|s| s.as_slice()) + .unwrap_or(&[]), + &[bump_seed], + ]; + + // Fund the stake account with the minimum + rent-exempt balance + let stake_space = std::mem::size_of::(); + let stake_minimum_delegation = stake::tools::get_minimum_delegation()?; + let required_lamports = minimum_delegation(stake_minimum_delegation) + .saturating_add(rent.minimum_balance(stake_space)); + + // Check that we're not draining the reserve totally + let reserve_stake = try_from_slice_unchecked::( + &reserve_stake_info.data.borrow(), + )?; + let reserve_meta = reserve_stake + .meta() + .ok_or(StakePoolError::WrongStakeStake)?; + let minimum_lamports = minimum_reserve_lamports(&reserve_meta); + let reserve_lamports = reserve_stake_info.lamports(); + if reserve_lamports.saturating_sub(required_lamports) < minimum_lamports { + msg!( + "Need to add {} lamports for the reserve stake to be rent-exempt after adding a validator, reserve currently has {} lamports", + required_lamports.saturating_add(minimum_lamports).saturating_sub(reserve_lamports), + reserve_lamports + ); + return Err(ProgramError::InsufficientFunds); + } + + // Create new stake account + create_stake_account(stake_info.clone(), stake_account_signer_seeds, stake_space)?; + // split into validator stake account + Self::stake_split( + stake_pool_info.key, + reserve_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + required_lamports, + stake_info.clone(), + )?; + + Self::stake_delegate( + stake_info.clone(), + validator_vote_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + + validator_list.push(ValidatorStakeInfo { + status: StakeStatus::Active.into(), + vote_account_address: *validator_vote_info.key, + active_stake_lamports: required_lamports.into(), + transient_stake_lamports: 0.into(), + last_update_epoch: clock.epoch.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: raw_validator_seed.into(), + })?; + + Ok(()) + } + + /// Processes `RemoveValidatorFromPool` instruction. + #[inline(never)] // needed due to stack size violation + fn process_remove_validator_from_pool( + program_id: &Pubkey, + accounts: &[AccountInfo], + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let stake_account_info = next_account_info(account_info_iter)?; + let transient_stake_account_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_stake_program(stake_program_info.key)?; + check_account_owner(stake_pool_info, program_id)?; + + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_staker(staker_info)?; + + if stake_pool.last_update_epoch < clock.epoch { + msg!( + "clock {} pool {}", + clock.epoch, + stake_pool.last_update_epoch + ); + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + stake_pool.check_validator_list(validator_list_info)?; + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let (_, stake) = get_stake_state(stake_account_info)?; + let vote_account_address = stake.delegation.voter_pubkey; + let maybe_validator_stake_info = validator_list.find_mut::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &vote_account_address) + }); + if maybe_validator_stake_info.is_none() { + msg!( + "Vote account {} not found in stake pool", + vote_account_address + ); + return Err(StakePoolError::ValidatorNotFound.into()); + } + let validator_stake_info = maybe_validator_stake_info.unwrap(); + check_validator_stake_address( + program_id, + stake_pool_info.key, + stake_account_info.key, + &vote_account_address, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + )?; + + if validator_stake_info.status != StakeStatus::Active.into() { + msg!("Validator is already marked for removal"); + return Err(StakePoolError::ValidatorNotFound.into()); + } + + let new_status = if u64::from(validator_stake_info.transient_stake_lamports) > 0 { + check_transient_stake_address( + program_id, + stake_pool_info.key, + transient_stake_account_info.key, + &vote_account_address, + validator_stake_info.transient_seed_suffix.into(), + )?; + + match get_stake_state(transient_stake_account_info) { + Ok((meta, stake)) + if stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) => + { + if stake.delegation.deactivation_epoch == Epoch::MAX { + Self::stake_deactivate( + transient_stake_account_info.clone(), + clock_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + } + StakeStatus::DeactivatingAll + } + _ => StakeStatus::DeactivatingValidator, + } + } else { + StakeStatus::DeactivatingValidator + }; + + // If the stake was force-deactivated through deactivate-delinquent or + // some other means, we *do not* need to deactivate it again + if stake.delegation.deactivation_epoch == Epoch::MAX { + Self::stake_deactivate( + stake_account_info.clone(), + clock_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + } + + validator_stake_info.status = new_status.into(); + + if stake_pool.preferred_deposit_validator_vote_address == Some(vote_account_address) { + stake_pool.preferred_deposit_validator_vote_address = None; + } + if stake_pool.preferred_withdraw_validator_vote_address == Some(vote_account_address) { + stake_pool.preferred_withdraw_validator_vote_address = None; + } + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + Ok(()) + } + + /// Processes `DecreaseValidatorStake` instruction. + #[inline(never)] // needed due to stack size violation + fn process_decrease_validator_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + lamports: u64, + transient_stake_seed: u64, + maybe_ephemeral_stake_seed: Option, + fund_rent_exempt_reserve: bool, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let maybe_reserve_stake_info = fund_rent_exempt_reserve + .then(|| next_account_info(account_info_iter)) + .transpose()?; + let validator_stake_account_info = next_account_info(account_info_iter)?; + let maybe_ephemeral_stake_account_info = maybe_ephemeral_stake_seed + .map(|_| next_account_info(account_info_iter)) + .transpose()?; + let transient_stake_account_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let (rent, maybe_stake_history_info) = + if maybe_ephemeral_stake_seed.is_some() || fund_rent_exempt_reserve { + (Rent::get()?, Some(next_account_info(account_info_iter)?)) + } else { + // legacy instruction takes the rent account + let rent_info = next_account_info(account_info_iter)?; + (Rent::from_account_info(rent_info)?, None) + }; + let system_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_system_program(system_program_info.key)?; + check_stake_program(stake_program_info.key)?; + check_account_owner(stake_pool_info, program_id)?; + + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + msg!("Expected valid stake pool"); + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_staker(staker_info)?; + + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + stake_pool.check_validator_list(validator_list_info)?; + check_account_owner(validator_list_info, program_id)?; + let validator_list_data = &mut *validator_list_info.data.borrow_mut(); + let (validator_list_header, mut validator_list) = + ValidatorListHeader::deserialize_vec(validator_list_data)?; + if !validator_list_header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + if let Some(reserve_stake_info) = maybe_reserve_stake_info { + stake_pool.check_reserve_stake(reserve_stake_info)?; + } + + let (meta, stake) = get_stake_state(validator_stake_account_info)?; + let vote_account_address = stake.delegation.voter_pubkey; + + let maybe_validator_stake_info = validator_list.find_mut::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &vote_account_address) + }); + if maybe_validator_stake_info.is_none() { + msg!( + "Vote account {} not found in stake pool", + vote_account_address + ); + return Err(StakePoolError::ValidatorNotFound.into()); + } + let validator_stake_info = maybe_validator_stake_info.unwrap(); + check_validator_stake_address( + program_id, + stake_pool_info.key, + validator_stake_account_info.key, + &vote_account_address, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + )?; + if u64::from(validator_stake_info.transient_stake_lamports) > 0 { + if maybe_ephemeral_stake_seed.is_none() { + msg!("Attempting to decrease stake on a validator with pending transient stake, use DecreaseAdditionalValidatorStake with the existing seed"); + return Err(StakePoolError::TransientAccountInUse.into()); + } + if transient_stake_seed != u64::from(validator_stake_info.transient_seed_suffix) { + msg!( + "Transient stake already exists with seed {}, you must use that one", + u64::from(validator_stake_info.transient_seed_suffix) + ); + return Err(ProgramError::InvalidSeeds); + } + check_if_stake_deactivating( + transient_stake_account_info, + &vote_account_address, + clock.epoch, + )?; + } + + let stake_space = std::mem::size_of::(); + let stake_rent = rent.minimum_balance(stake_space); + + let stake_minimum_delegation = stake::tools::get_minimum_delegation()?; + let current_minimum_lamports = minimum_delegation(stake_minimum_delegation); + if lamports < current_minimum_lamports { + msg!( + "Need at least {} lamports for transient stake to meet minimum delegation and rent-exempt requirements, {} provided", + current_minimum_lamports, + lamports + ); + return Err(ProgramError::AccountNotRentExempt); + } + + let remaining_lamports = validator_stake_account_info + .lamports() + .checked_sub(lamports) + .ok_or(ProgramError::InsufficientFunds)?; + let required_lamports = minimum_stake_lamports(&meta, stake_minimum_delegation); + if remaining_lamports < required_lamports { + msg!("Need at least {} lamports in the stake account after decrease, {} requested, {} is the current possible maximum", + required_lamports, + lamports, + validator_stake_account_info.lamports().checked_sub(required_lamports).ok_or(StakePoolError::CalculationFailure)? + ); + return Err(ProgramError::InsufficientFunds); + } + + let (source_stake_account_info, split_lamports) = + if let Some((ephemeral_stake_seed, ephemeral_stake_account_info)) = + maybe_ephemeral_stake_seed.zip(maybe_ephemeral_stake_account_info) + { + let ephemeral_stake_bump_seed = check_ephemeral_stake_address( + program_id, + stake_pool_info.key, + ephemeral_stake_account_info.key, + ephemeral_stake_seed, + )?; + let ephemeral_stake_account_signer_seeds: &[&[_]] = &[ + EPHEMERAL_STAKE_SEED_PREFIX, + stake_pool_info.key.as_ref(), + &ephemeral_stake_seed.to_le_bytes(), + &[ephemeral_stake_bump_seed], + ]; + create_stake_account( + ephemeral_stake_account_info.clone(), + ephemeral_stake_account_signer_seeds, + stake_space, + )?; + + // if needed, withdraw rent-exempt reserve for ephemeral account + if let Some(reserve_stake_info) = maybe_reserve_stake_info { + let required_lamports_for_rent_exemption = + stake_rent.saturating_sub(ephemeral_stake_account_info.lamports()); + if required_lamports_for_rent_exemption > 0 { + if required_lamports_for_rent_exemption >= reserve_stake_info.lamports() { + return Err(StakePoolError::ReserveDepleted.into()); + } + let stake_history_info = maybe_stake_history_info + .ok_or(StakePoolError::MissingRequiredSysvar)?; + Self::stake_withdraw( + stake_pool_info.key, + reserve_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + ephemeral_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + required_lamports_for_rent_exemption, + )?; + } + } + + // split into ephemeral stake account + Self::stake_split( + stake_pool_info.key, + validator_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + lamports, + ephemeral_stake_account_info.clone(), + )?; + + Self::stake_deactivate( + ephemeral_stake_account_info.clone(), + clock_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + + ( + ephemeral_stake_account_info, + ephemeral_stake_account_info.lamports(), + ) + } else { + // if no ephemeral account is provided, split everything from the + // validator stake account, into the transient stake account + (validator_stake_account_info, lamports) + }; + + let transient_stake_bump_seed = check_transient_stake_address( + program_id, + stake_pool_info.key, + transient_stake_account_info.key, + &vote_account_address, + transient_stake_seed, + )?; + + if u64::from(validator_stake_info.transient_stake_lamports) > 0 { + let stake_history_info = maybe_stake_history_info.unwrap(); + // transient stake exists, try to merge from the source account, + // which is always an ephemeral account + Self::stake_merge( + stake_pool_info.key, + source_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + transient_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + } else { + let transient_stake_account_signer_seeds: &[&[_]] = &[ + TRANSIENT_STAKE_SEED_PREFIX, + vote_account_address.as_ref(), + stake_pool_info.key.as_ref(), + &transient_stake_seed.to_le_bytes(), + &[transient_stake_bump_seed], + ]; + + create_stake_account( + transient_stake_account_info.clone(), + transient_stake_account_signer_seeds, + stake_space, + )?; + + // if needed, withdraw rent-exempt reserve for transient account + if let Some(reserve_stake_info) = maybe_reserve_stake_info { + let required_lamports = + stake_rent.saturating_sub(transient_stake_account_info.lamports()); + // in the case of doing a full split from an ephemeral account, + // the rent-exempt reserve moves over, so no need to fund it from + // the pool reserve + if source_stake_account_info.lamports() != split_lamports { + let stake_history_info = + maybe_stake_history_info.ok_or(StakePoolError::MissingRequiredSysvar)?; + if required_lamports >= reserve_stake_info.lamports() { + return Err(StakePoolError::ReserveDepleted.into()); + } + if required_lamports > 0 { + Self::stake_withdraw( + stake_pool_info.key, + reserve_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + transient_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + required_lamports, + )?; + } + } + } + + // split into transient stake account + Self::stake_split( + stake_pool_info.key, + source_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + split_lamports, + transient_stake_account_info.clone(), + )?; + + // Deactivate transient stake if necessary + let (_, stake) = get_stake_state(transient_stake_account_info)?; + if stake.delegation.deactivation_epoch == Epoch::MAX { + Self::stake_deactivate( + transient_stake_account_info.clone(), + clock_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + } + } + + validator_stake_info.active_stake_lamports = + u64::from(validator_stake_info.active_stake_lamports) + .checked_sub(lamports) + .ok_or(StakePoolError::CalculationFailure)? + .into(); + validator_stake_info.transient_stake_lamports = + transient_stake_account_info.lamports().into(); + validator_stake_info.transient_seed_suffix = transient_stake_seed.into(); + + Ok(()) + } + + /// Processes `IncreaseValidatorStake` instruction. + #[inline(never)] // needed due to stack size violation + fn process_increase_validator_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + lamports: u64, + transient_stake_seed: u64, + maybe_ephemeral_stake_seed: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let reserve_stake_account_info = next_account_info(account_info_iter)?; + let maybe_ephemeral_stake_account_info = maybe_ephemeral_stake_seed + .map(|_| next_account_info(account_info_iter)) + .transpose()?; + let transient_stake_account_info = next_account_info(account_info_iter)?; + let validator_stake_account_info = next_account_info(account_info_iter)?; + let validator_vote_account_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let rent = if maybe_ephemeral_stake_seed.is_some() { + // instruction with ephemeral account doesn't take the rent account + Rent::get()? + } else { + // legacy instruction takes the rent account + let rent_info = next_account_info(account_info_iter)?; + Rent::from_account_info(rent_info)? + }; + let stake_history_info = next_account_info(account_info_iter)?; + let stake_config_info = next_account_info(account_info_iter)?; + let system_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_system_program(system_program_info.key)?; + check_stake_program(stake_program_info.key)?; + check_account_owner(stake_pool_info, program_id)?; + + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + msg!("Expected valid stake pool"); + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_staker(staker_info)?; + + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + stake_pool.check_validator_list(validator_list_info)?; + stake_pool.check_reserve_stake(reserve_stake_account_info)?; + check_account_owner(validator_list_info, program_id)?; + + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let vote_account_address = validator_vote_account_info.key; + + let maybe_validator_stake_info = validator_list.find_mut::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, vote_account_address) + }); + if maybe_validator_stake_info.is_none() { + msg!( + "Vote account {} not found in stake pool", + vote_account_address + ); + return Err(StakePoolError::ValidatorNotFound.into()); + } + let validator_stake_info = maybe_validator_stake_info.unwrap(); + if u64::from(validator_stake_info.transient_stake_lamports) > 0 { + if maybe_ephemeral_stake_seed.is_none() { + msg!("Attempting to increase stake on a validator with pending transient stake, use IncreaseAdditionalValidatorStake with the existing seed"); + return Err(StakePoolError::TransientAccountInUse.into()); + } + if transient_stake_seed != u64::from(validator_stake_info.transient_seed_suffix) { + msg!( + "Transient stake already exists with seed {}, you must use that one", + u64::from(validator_stake_info.transient_seed_suffix) + ); + return Err(ProgramError::InvalidSeeds); + } + check_if_stake_activating( + transient_stake_account_info, + vote_account_address, + clock.epoch, + )?; + } + + check_validator_stake_account( + validator_stake_account_info, + program_id, + stake_pool_info.key, + withdraw_authority_info.key, + vote_account_address, + validator_stake_info.validator_seed_suffix.into(), + &stake_pool.lockup, + )?; + + if validator_stake_info.status != StakeStatus::Active.into() { + msg!("Validator is marked for removal and no longer allows increases"); + return Err(StakePoolError::ValidatorNotFound.into()); + } + + let stake_space = std::mem::size_of::(); + let stake_rent = rent.minimum_balance(stake_space); + let stake_minimum_delegation = stake::tools::get_minimum_delegation()?; + let current_minimum_delegation = minimum_delegation(stake_minimum_delegation); + if lamports < current_minimum_delegation { + msg!( + "Need more than {} lamports for transient stake to meet minimum delegation requirement, {} provided", + current_minimum_delegation, + lamports + ); + return Err(ProgramError::Custom( + stake::instruction::StakeError::InsufficientDelegation as u32, + )); + } + + // the stake account rent exemption is withdrawn after the merge, so + // to add `lamports` to a validator, we need to create a stake account + // with `lamports + stake_rent` + let total_lamports = lamports.saturating_add(stake_rent); + + if reserve_stake_account_info + .lamports() + .saturating_sub(total_lamports) + < stake_rent + { + let max_split_amount = reserve_stake_account_info + .lamports() + .saturating_sub(stake_rent.saturating_mul(2)); + msg!( + "Reserve stake does not have enough lamports for increase, maximum amount {}, {} requested", + max_split_amount, + lamports + ); + return Err(ProgramError::InsufficientFunds); + } + + let source_stake_account_info = + if let Some((ephemeral_stake_seed, ephemeral_stake_account_info)) = + maybe_ephemeral_stake_seed.zip(maybe_ephemeral_stake_account_info) + { + let ephemeral_stake_bump_seed = check_ephemeral_stake_address( + program_id, + stake_pool_info.key, + ephemeral_stake_account_info.key, + ephemeral_stake_seed, + )?; + let ephemeral_stake_account_signer_seeds: &[&[_]] = &[ + EPHEMERAL_STAKE_SEED_PREFIX, + stake_pool_info.key.as_ref(), + &ephemeral_stake_seed.to_le_bytes(), + &[ephemeral_stake_bump_seed], + ]; + create_stake_account( + ephemeral_stake_account_info.clone(), + ephemeral_stake_account_signer_seeds, + stake_space, + )?; + + // split into ephemeral stake account + Self::stake_split( + stake_pool_info.key, + reserve_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + total_lamports, + ephemeral_stake_account_info.clone(), + )?; + + // activate stake to validator + Self::stake_delegate( + ephemeral_stake_account_info.clone(), + validator_vote_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + ephemeral_stake_account_info + } else { + // if no ephemeral account is provided, split everything from the + // reserve account, into the transient stake account + reserve_stake_account_info + }; + + let transient_stake_bump_seed = check_transient_stake_address( + program_id, + stake_pool_info.key, + transient_stake_account_info.key, + vote_account_address, + transient_stake_seed, + )?; + + if u64::from(validator_stake_info.transient_stake_lamports) > 0 { + // transient stake exists, try to merge from the source account, + // which is always an ephemeral account + Self::stake_merge( + stake_pool_info.key, + source_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + transient_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + } else { + // no transient stake, split + let transient_stake_account_signer_seeds: &[&[_]] = &[ + TRANSIENT_STAKE_SEED_PREFIX, + vote_account_address.as_ref(), + stake_pool_info.key.as_ref(), + &transient_stake_seed.to_le_bytes(), + &[transient_stake_bump_seed], + ]; + + create_stake_account( + transient_stake_account_info.clone(), + transient_stake_account_signer_seeds, + stake_space, + )?; + + // split into transient stake account + Self::stake_split( + stake_pool_info.key, + source_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + total_lamports, + transient_stake_account_info.clone(), + )?; + + // Activate transient stake to validator if necessary + let stake_state = try_from_slice_unchecked::( + &transient_stake_account_info.data.borrow(), + )?; + match stake_state { + // if it was delegated on or before this epoch, we're good + stake::state::StakeStateV2::Stake(_, stake, _) + if stake.delegation.activation_epoch <= clock.epoch => {} + // all other situations, delegate! + _ => { + Self::stake_delegate( + transient_stake_account_info.clone(), + validator_vote_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + withdraw_authority_info.clone(), + stake_pool_info.key, + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + )?; + } + } + } + + validator_stake_info.transient_stake_lamports = + u64::from(validator_stake_info.transient_stake_lamports) + .checked_add(total_lamports) + .ok_or(StakePoolError::CalculationFailure)? + .into(); + validator_stake_info.transient_seed_suffix = transient_stake_seed.into(); + + Ok(()) + } + + /// Process `SetPreferredValidator` instruction + #[inline(never)] // needed due to stack size violation + fn process_set_preferred_validator( + program_id: &Pubkey, + accounts: &[AccountInfo], + validator_type: PreferredValidatorType, + vote_account_address: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let staker_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + + check_account_owner(stake_pool_info, program_id)?; + check_account_owner(validator_list_info, program_id)?; + + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + msg!("Expected valid stake pool"); + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_staker(staker_info)?; + stake_pool.check_validator_list(validator_list_info)?; + + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + if let Some(vote_account_address) = vote_account_address { + let maybe_validator_stake_info = validator_list.find::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &vote_account_address) + }); + match maybe_validator_stake_info { + Some(vsi) => { + if vsi.status != StakeStatus::Active.into() { + msg!("Validator for {:?} about to be removed, cannot set as preferred deposit account", validator_type); + return Err(StakePoolError::InvalidPreferredValidator.into()); + } + } + None => { + msg!("Validator for {:?} not present in the stake pool, cannot set as preferred deposit account", validator_type); + return Err(StakePoolError::ValidatorNotFound.into()); + } + } + } + + match validator_type { + PreferredValidatorType::Deposit => { + stake_pool.preferred_deposit_validator_vote_address = vote_account_address + } + PreferredValidatorType::Withdraw => { + stake_pool.preferred_withdraw_validator_vote_address = vote_account_address + } + }; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + Ok(()) + } + + /// Processes `UpdateValidatorListBalance` instruction. + #[inline(always)] // needed to maximize number of validators + fn process_update_validator_list_balance( + program_id: &Pubkey, + accounts: &[AccountInfo], + start_index: u32, + no_merge: bool, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let stake_history_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + let validator_stake_accounts = account_info_iter.as_slice(); + + check_account_owner(stake_pool_info, program_id)?; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + stake_pool.check_validator_list(validator_list_info)?; + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_reserve_stake(reserve_stake_info)?; + check_stake_program(stake_program_info.key)?; + + if validator_stake_accounts + .len() + .checked_rem(2) + .ok_or(StakePoolError::CalculationFailure)? + != 0 + { + msg!("Odd number of validator stake accounts passed in, should be pairs of validator stake and transient stake accounts"); + return Err(StakePoolError::UnexpectedValidatorListAccountSize.into()); + } + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (validator_list_header, mut big_vec) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + let validator_slice = ValidatorListHeader::deserialize_mut_slice( + &mut big_vec, + start_index as usize, + validator_stake_accounts.len() / 2, + )?; + + if !validator_list_header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let validator_iter = &mut validator_slice + .iter_mut() + .zip(validator_stake_accounts.chunks_exact(2)); + for (validator_stake_record, validator_stakes) in validator_iter { + // chunks_exact means that we always get 2 elements, making this safe + let validator_stake_info = validator_stakes + .first() + .ok_or(ProgramError::InvalidInstructionData)?; + let transient_stake_info = validator_stakes + .last() + .ok_or(ProgramError::InvalidInstructionData)?; + if check_validator_stake_address( + program_id, + stake_pool_info.key, + validator_stake_info.key, + &validator_stake_record.vote_account_address, + NonZeroU32::new(validator_stake_record.validator_seed_suffix.into()), + ) + .is_err() + { + continue; + }; + if check_transient_stake_address( + program_id, + stake_pool_info.key, + transient_stake_info.key, + &validator_stake_record.vote_account_address, + validator_stake_record.transient_seed_suffix.into(), + ) + .is_err() + { + continue; + }; + + let mut active_stake_lamports = 0; + let mut transient_stake_lamports = 0; + let validator_stake_state = try_from_slice_unchecked::( + &validator_stake_info.data.borrow(), + ) + .ok(); + let transient_stake_state = try_from_slice_unchecked::( + &transient_stake_info.data.borrow(), + ) + .ok(); + + // Possible merge situations for transient stake + // * active -> merge into validator stake + // * activating -> nothing, just account its lamports + // * deactivating -> nothing, just account its lamports + // * inactive -> merge into reserve stake + // * not a stake -> ignore + match transient_stake_state { + Some(stake::state::StakeStateV2::Initialized(meta)) => { + if stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) { + if no_merge { + transient_stake_lamports = transient_stake_info.lamports(); + } else { + // merge into reserve + Self::stake_merge( + stake_pool_info.key, + transient_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + validator_stake_record.status.remove_transient_stake()?; + } + } + } + Some(stake::state::StakeStateV2::Stake(meta, stake, _)) => { + if stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) { + if no_merge { + transient_stake_lamports = transient_stake_info.lamports(); + } else if stake_is_inactive_without_history(&stake, clock.epoch) { + // deactivated, merge into reserve + Self::stake_merge( + stake_pool_info.key, + transient_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + validator_stake_record.status.remove_transient_stake()?; + } else if stake.delegation.activation_epoch < clock.epoch { + if let Some(stake::state::StakeStateV2::Stake(_, validator_stake, _)) = + validator_stake_state + { + if validator_stake.delegation.activation_epoch < clock.epoch { + Self::stake_merge( + stake_pool_info.key, + transient_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + validator_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + } else { + msg!("Stake activating or just active, not ready to merge"); + transient_stake_lamports = transient_stake_info.lamports(); + } + } else { + msg!("Transient stake is activating or active, but validator stake is not, need to add the validator stake account on {} back into the stake pool", stake.delegation.voter_pubkey); + transient_stake_lamports = transient_stake_info.lamports(); + } + } else { + msg!("Transient stake not ready to be merged anywhere"); + transient_stake_lamports = transient_stake_info.lamports(); + } + } + } + None + | Some(stake::state::StakeStateV2::Uninitialized) + | Some(stake::state::StakeStateV2::RewardsPool) => {} // do nothing + } + + // Status for validator stake + // * active -> do everything + // * any other state / not a stake -> error state, but account for transient + // stake + let validator_stake_state = try_from_slice_unchecked::( + &validator_stake_info.data.borrow(), + ) + .ok(); + match validator_stake_state { + Some(stake::state::StakeStateV2::Stake(meta, stake, _)) => { + let additional_lamports = validator_stake_info + .lamports() + .saturating_sub(stake.delegation.stake) + .saturating_sub(meta.rent_exempt_reserve); + // withdraw any extra lamports back to the reserve + if additional_lamports > 0 + && stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) + { + Self::stake_withdraw( + stake_pool_info.key, + validator_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + additional_lamports, + )?; + } + match validator_stake_record.status.try_into()? { + StakeStatus::Active => { + active_stake_lamports = validator_stake_info.lamports(); + } + StakeStatus::DeactivatingValidator | StakeStatus::DeactivatingAll => { + if no_merge { + active_stake_lamports = validator_stake_info.lamports(); + } else if stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) && stake_is_inactive_without_history(&stake, clock.epoch) + { + // Validator was removed through normal means. + // Absorb the lamports into the reserve. + Self::stake_merge( + stake_pool_info.key, + validator_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + validator_stake_record.status.remove_validator_stake()?; + } + } + StakeStatus::DeactivatingTransient | StakeStatus::ReadyForRemoval => { + msg!("Validator stake account no longer part of the pool, ignoring"); + } + } + } + Some(stake::state::StakeStateV2::Initialized(meta)) + if stake_is_usable_by_pool( + &meta, + withdraw_authority_info.key, + &stake_pool.lockup, + ) => + { + // If a validator stake is `Initialized`, the validator could + // have been destaked during a cluster restart or removed through + // normal means. Either way, absorb those lamports into the reserve. + // The transient stake was likely absorbed into the reserve earlier. + Self::stake_merge( + stake_pool_info.key, + validator_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + validator_stake_record.status.remove_validator_stake()?; + } + Some(stake::state::StakeStateV2::Initialized(_)) + | Some(stake::state::StakeStateV2::Uninitialized) + | Some(stake::state::StakeStateV2::RewardsPool) + | None => { + msg!("Validator stake account no longer part of the pool, ignoring"); + } + } + + validator_stake_record.last_update_epoch = clock.epoch.into(); + validator_stake_record.active_stake_lamports = active_stake_lamports.into(); + validator_stake_record.transient_stake_lamports = transient_stake_lamports.into(); + } + + Ok(()) + } + + /// Processes `UpdateStakePoolBalance` instruction. + #[inline(always)] // needed to optimize number of validators + fn process_update_stake_pool_balance( + program_id: &Pubkey, + accounts: &[AccountInfo], + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let withdraw_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + let clock = Clock::get()?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + stake_pool.check_mint(pool_mint_info)?; + stake_pool.check_authority_withdraw(withdraw_info.key, program_id, stake_pool_info.key)?; + stake_pool.check_reserve_stake(reserve_stake_info)?; + if stake_pool.manager_fee_account != *manager_fee_info.key { + return Err(StakePoolError::InvalidFeeAccount.into()); + } + + if *validator_list_info.key != stake_pool.validator_list { + return Err(StakePoolError::InvalidValidatorStakeList.into()); + } + if stake_pool.token_program_id != *token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let previous_lamports = stake_pool.total_lamports; + let previous_pool_token_supply = stake_pool.pool_token_supply; + let reserve_stake = try_from_slice_unchecked::( + &reserve_stake_info.data.borrow(), + )?; + let mut total_lamports = + if let stake::state::StakeStateV2::Initialized(meta) = reserve_stake { + reserve_stake_info + .lamports() + .checked_sub(minimum_reserve_lamports(&meta)) + .ok_or(StakePoolError::CalculationFailure)? + } else { + msg!("Reserve stake account in unknown state, aborting"); + return Err(StakePoolError::WrongStakeStake.into()); + }; + for validator_stake_record in validator_list + .deserialize_slice::(0, validator_list.len() as usize)? + { + if u64::from(validator_stake_record.last_update_epoch) < clock.epoch { + return Err(StakePoolError::StakeListOutOfDate.into()); + } + total_lamports = total_lamports + .checked_add(validator_stake_record.stake_lamports()?) + .ok_or(StakePoolError::CalculationFailure)?; + } + + let reward_lamports = total_lamports.saturating_sub(previous_lamports); + + // If the manager fee info is invalid, they don't deserve to receive the fee. + let fee = if stake_pool.check_manager_fee_info(manager_fee_info).is_ok() { + stake_pool + .calc_epoch_fee_amount(reward_lamports) + .ok_or(StakePoolError::CalculationFailure)? + } else { + 0 + }; + + if fee > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + withdraw_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + fee, + )?; + } + + if stake_pool.last_update_epoch < clock.epoch { + if let Some(fee) = stake_pool.next_epoch_fee.get() { + stake_pool.epoch_fee = *fee; + } + stake_pool.next_epoch_fee.update_epoch(); + + if let Some(fee) = stake_pool.next_stake_withdrawal_fee.get() { + stake_pool.stake_withdrawal_fee = *fee; + } + stake_pool.next_stake_withdrawal_fee.update_epoch(); + + if let Some(fee) = stake_pool.next_sol_withdrawal_fee.get() { + stake_pool.sol_withdrawal_fee = *fee; + } + stake_pool.next_sol_withdrawal_fee.update_epoch(); + + stake_pool.last_update_epoch = clock.epoch; + stake_pool.last_epoch_total_lamports = previous_lamports; + stake_pool.last_epoch_pool_token_supply = previous_pool_token_supply; + } + stake_pool.total_lamports = total_lamports; + + let pool_mint_data = pool_mint_info.try_borrow_data()?; + let pool_mint = StateWithExtensions::::unpack(&pool_mint_data)?; + stake_pool.pool_token_supply = pool_mint.base.supply; + + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + Ok(()) + } + + /// Processes the `CleanupRemovedValidatorEntries` instruction + #[inline(never)] // needed to avoid stack size violation + fn process_cleanup_removed_validator_entries( + program_id: &Pubkey, + accounts: &[AccountInfo], + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + + check_account_owner(stake_pool_info, program_id)?; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + stake_pool.check_validator_list(validator_list_info)?; + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + validator_list.retain::(ValidatorStakeInfo::is_not_removed)?; + + Ok(()) + } + + /// Processes [DepositStake](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_deposit_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + minimum_pool_tokens_out: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let stake_deposit_authority_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let stake_info = next_account_info(account_info_iter)?; + let validator_stake_account_info = next_account_info(account_info_iter)?; + let reserve_stake_account_info = next_account_info(account_info_iter)?; + let dest_user_pool_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let referrer_fee_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let stake_history_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_stake_program(stake_program_info.key)?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_stake_deposit_authority(stake_deposit_authority_info.key)?; + stake_pool.check_mint(pool_mint_info)?; + stake_pool.check_validator_list(validator_list_info)?; + stake_pool.check_reserve_stake(reserve_stake_account_info)?; + + if stake_pool.token_program_id != *token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + + if stake_pool.manager_fee_account != *manager_fee_info.key { + return Err(StakePoolError::InvalidFeeAccount.into()); + } + // There is no bypass if the manager fee account is invalid. Deposits + // don't hold user funds hostage, so if the fee account is invalid, users + // cannot deposit in the pool. Let it fail here! + + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let (_, validator_stake) = get_stake_state(validator_stake_account_info)?; + let pre_all_validator_lamports = validator_stake_account_info.lamports(); + let vote_account_address = validator_stake.delegation.voter_pubkey; + if let Some(preferred_deposit) = stake_pool.preferred_deposit_validator_vote_address { + if preferred_deposit != vote_account_address { + msg!( + "Incorrect deposit address, expected {}, received {}", + preferred_deposit, + vote_account_address + ); + return Err(StakePoolError::IncorrectDepositVoteAddress.into()); + } + } + + let validator_stake_info = validator_list + .find_mut::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &vote_account_address) + }) + .ok_or(StakePoolError::ValidatorNotFound)?; + check_validator_stake_address( + program_id, + stake_pool_info.key, + validator_stake_account_info.key, + &vote_account_address, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + )?; + + if validator_stake_info.status != StakeStatus::Active.into() { + msg!("Validator is marked for removal and no longer accepting deposits"); + return Err(StakePoolError::ValidatorNotFound.into()); + } + + msg!("Stake pre merge {}", validator_stake.delegation.stake); + + let (stake_deposit_authority_program_address, deposit_bump_seed) = + find_deposit_authority_program_address(program_id, stake_pool_info.key); + if *stake_deposit_authority_info.key == stake_deposit_authority_program_address { + Self::stake_authorize_signed( + stake_pool_info.key, + stake_info.clone(), + stake_deposit_authority_info.clone(), + AUTHORITY_DEPOSIT, + deposit_bump_seed, + withdraw_authority_info.key, + clock_info.clone(), + )?; + } else { + Self::stake_authorize( + stake_info.clone(), + stake_deposit_authority_info.clone(), + withdraw_authority_info.key, + clock_info.clone(), + )?; + } + + Self::stake_merge( + stake_pool_info.key, + stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + validator_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + + let (_, post_validator_stake) = get_stake_state(validator_stake_account_info)?; + let post_all_validator_lamports = validator_stake_account_info.lamports(); + msg!("Stake post merge {}", post_validator_stake.delegation.stake); + + let total_deposit_lamports = post_all_validator_lamports + .checked_sub(pre_all_validator_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + let stake_deposit_lamports = post_validator_stake + .delegation + .stake + .checked_sub(validator_stake.delegation.stake) + .ok_or(StakePoolError::CalculationFailure)?; + let sol_deposit_lamports = total_deposit_lamports + .checked_sub(stake_deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + + let new_pool_tokens = stake_pool + .calc_pool_tokens_for_deposit(total_deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + let new_pool_tokens_from_stake = stake_pool + .calc_pool_tokens_for_deposit(stake_deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + let new_pool_tokens_from_sol = new_pool_tokens + .checked_sub(new_pool_tokens_from_stake) + .ok_or(StakePoolError::CalculationFailure)?; + + let stake_deposit_fee = stake_pool + .calc_pool_tokens_stake_deposit_fee(new_pool_tokens_from_stake) + .ok_or(StakePoolError::CalculationFailure)?; + let sol_deposit_fee = stake_pool + .calc_pool_tokens_sol_deposit_fee(new_pool_tokens_from_sol) + .ok_or(StakePoolError::CalculationFailure)?; + + let total_fee = stake_deposit_fee + .checked_add(sol_deposit_fee) + .ok_or(StakePoolError::CalculationFailure)?; + let pool_tokens_user = new_pool_tokens + .checked_sub(total_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + let pool_tokens_referral_fee = stake_pool + .calc_pool_tokens_stake_referral_fee(total_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + let pool_tokens_manager_deposit_fee = total_fee + .checked_sub(pool_tokens_referral_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + if pool_tokens_user + .saturating_add(pool_tokens_manager_deposit_fee) + .saturating_add(pool_tokens_referral_fee) + != new_pool_tokens + { + return Err(StakePoolError::CalculationFailure.into()); + } + + if pool_tokens_user == 0 { + return Err(StakePoolError::DepositTooSmall.into()); + } + + if let Some(minimum_pool_tokens_out) = minimum_pool_tokens_out { + if pool_tokens_user < minimum_pool_tokens_out { + return Err(StakePoolError::ExceededSlippage.into()); + } + } + + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + dest_user_pool_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_user, + )?; + if pool_tokens_manager_deposit_fee > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_manager_deposit_fee, + )?; + } + if pool_tokens_referral_fee > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + referrer_fee_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_referral_fee, + )?; + } + + // withdraw additional lamports to the reserve + if sol_deposit_lamports > 0 { + Self::stake_withdraw( + stake_pool_info.key, + validator_stake_account_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + reserve_stake_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + sol_deposit_lamports, + )?; + } + + stake_pool.pool_token_supply = stake_pool + .pool_token_supply + .checked_add(new_pool_tokens) + .ok_or(StakePoolError::CalculationFailure)?; + // We treat the extra lamports as though they were + // transferred directly to the reserve stake account. + stake_pool.total_lamports = stake_pool + .total_lamports + .checked_add(total_deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + validator_stake_info.active_stake_lamports = validator_stake_account_info.lamports().into(); + + Ok(()) + } + + /// Processes [DepositSol](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_deposit_sol( + program_id: &Pubkey, + accounts: &[AccountInfo], + deposit_lamports: u64, + minimum_pool_tokens_out: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let reserve_stake_account_info = next_account_info(account_info_iter)?; + let from_user_lamports_info = next_account_info(account_info_iter)?; + let dest_user_pool_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let referrer_fee_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let system_program_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + let sol_deposit_authority_info = next_account_info(account_info_iter); + + let clock = Clock::get()?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_sol_deposit_authority(sol_deposit_authority_info)?; + stake_pool.check_mint(pool_mint_info)?; + stake_pool.check_reserve_stake(reserve_stake_account_info)?; + + if stake_pool.token_program_id != *token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + check_system_program(system_program_info.key)?; + + if stake_pool.manager_fee_account != *manager_fee_info.key { + return Err(StakePoolError::InvalidFeeAccount.into()); + } + // There is no bypass if the manager fee account is invalid. Deposits + // don't hold user funds hostage, so if the fee account is invalid, users + // cannot deposit in the pool. Let it fail here! + + // We want this to hold to ensure that deposit_sol mints pool tokens + // at the right price + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + let new_pool_tokens = stake_pool + .calc_pool_tokens_for_deposit(deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + + let pool_tokens_sol_deposit_fee = stake_pool + .calc_pool_tokens_sol_deposit_fee(new_pool_tokens) + .ok_or(StakePoolError::CalculationFailure)?; + let pool_tokens_user = new_pool_tokens + .checked_sub(pool_tokens_sol_deposit_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + let pool_tokens_referral_fee = stake_pool + .calc_pool_tokens_sol_referral_fee(pool_tokens_sol_deposit_fee) + .ok_or(StakePoolError::CalculationFailure)?; + let pool_tokens_manager_deposit_fee = pool_tokens_sol_deposit_fee + .checked_sub(pool_tokens_referral_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + if pool_tokens_user + .saturating_add(pool_tokens_manager_deposit_fee) + .saturating_add(pool_tokens_referral_fee) + != new_pool_tokens + { + return Err(StakePoolError::CalculationFailure.into()); + } + + if pool_tokens_user == 0 { + return Err(StakePoolError::DepositTooSmall.into()); + } + + if let Some(minimum_pool_tokens_out) = minimum_pool_tokens_out { + if pool_tokens_user < minimum_pool_tokens_out { + return Err(StakePoolError::ExceededSlippage.into()); + } + } + + Self::sol_transfer( + from_user_lamports_info.clone(), + reserve_stake_account_info.clone(), + deposit_lamports, + )?; + + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + dest_user_pool_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_user, + )?; + + if pool_tokens_manager_deposit_fee > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_manager_deposit_fee, + )?; + } + + if pool_tokens_referral_fee > 0 { + Self::token_mint_to( + stake_pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + referrer_fee_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + pool_tokens_referral_fee, + )?; + } + + stake_pool.pool_token_supply = stake_pool + .pool_token_supply + .checked_add(new_pool_tokens) + .ok_or(StakePoolError::CalculationFailure)?; + stake_pool.total_lamports = stake_pool + .total_lamports + .checked_add(deposit_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + Ok(()) + } + + /// Processes [WithdrawStake](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_withdraw_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + pool_tokens: u64, + minimum_lamports_out: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let validator_list_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let stake_split_from = next_account_info(account_info_iter)?; + let stake_split_to = next_account_info(account_info_iter)?; + let user_stake_authority_info = next_account_info(account_info_iter)?; + let user_transfer_authority_info = next_account_info(account_info_iter)?; + let burn_from_pool_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(clock_info)?; + let token_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_stake_program(stake_program_info.key)?; + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let decimals = stake_pool.check_mint(pool_mint_info)?; + stake_pool.check_validator_list(validator_list_info)?; + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + + if stake_pool.manager_fee_account != *manager_fee_info.key { + return Err(StakePoolError::InvalidFeeAccount.into()); + } + if stake_pool.token_program_id != *token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + + if stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + check_account_owner(validator_list_info, program_id)?; + let mut validator_list_data = validator_list_info.data.borrow_mut(); + let (header, mut validator_list) = + ValidatorListHeader::deserialize_vec(&mut validator_list_data)?; + if !header.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + // To prevent a faulty manager fee account from preventing withdrawals + // if the token program does not own the account, or if the account is not + // initialized + let pool_tokens_fee = if stake_pool.manager_fee_account == *burn_from_pool_info.key + || stake_pool.check_manager_fee_info(manager_fee_info).is_err() + { + 0 + } else { + stake_pool + .calc_pool_tokens_stake_withdrawal_fee(pool_tokens) + .ok_or(StakePoolError::CalculationFailure)? + }; + let pool_tokens_burnt = pool_tokens + .checked_sub(pool_tokens_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + let mut withdraw_lamports = stake_pool + .calc_lamports_withdraw_amount(pool_tokens_burnt) + .ok_or(StakePoolError::CalculationFailure)?; + + if withdraw_lamports == 0 { + return Err(StakePoolError::WithdrawalTooSmall.into()); + } + + if let Some(minimum_lamports_out) = minimum_lamports_out { + if withdraw_lamports < minimum_lamports_out { + return Err(StakePoolError::ExceededSlippage.into()); + } + } + + let stake_minimum_delegation = stake::tools::get_minimum_delegation()?; + let stake_state = try_from_slice_unchecked::( + &stake_split_from.data.borrow(), + )?; + let meta = stake_state.meta().ok_or(StakePoolError::WrongStakeStake)?; + let required_lamports = minimum_stake_lamports(&meta, stake_minimum_delegation); + + let lamports_per_pool_token = stake_pool + .get_lamports_per_pool_token() + .ok_or(StakePoolError::CalculationFailure)?; + let minimum_lamports_with_tolerance = + required_lamports.saturating_add(lamports_per_pool_token); + + let has_active_stake = validator_list + .find::(|x| { + ValidatorStakeInfo::active_lamports_greater_than( + x, + &minimum_lamports_with_tolerance, + ) + }) + .is_some(); + let has_transient_stake = validator_list + .find::(|x| { + ValidatorStakeInfo::transient_lamports_greater_than( + x, + &minimum_lamports_with_tolerance, + ) + }) + .is_some(); + + let validator_list_item_info = if *stake_split_from.key == stake_pool.reserve_stake { + // check that the validator stake accounts have no withdrawable stake + if has_transient_stake || has_active_stake { + msg!("Error withdrawing from reserve: validator stake accounts have lamports available, please use those first."); + return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into()); + } + + // check that reserve has enough (should never fail, but who knows?) + stake_split_from + .lamports() + .checked_sub(minimum_reserve_lamports(&meta)) + .ok_or(StakePoolError::StakeLamportsNotEqualToMinimum)?; + None + } else { + let delegation = stake_state + .delegation() + .ok_or(StakePoolError::WrongStakeStake)?; + let vote_account_address = delegation.voter_pubkey; + + if let Some(preferred_withdraw_validator) = + stake_pool.preferred_withdraw_validator_vote_address + { + let preferred_validator_info = validator_list + .find::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &preferred_withdraw_validator) + }) + .ok_or(StakePoolError::ValidatorNotFound)?; + let available_lamports = u64::from(preferred_validator_info.active_stake_lamports) + .saturating_sub(minimum_lamports_with_tolerance); + if preferred_withdraw_validator != vote_account_address && available_lamports > 0 { + msg!("Validator vote address {} is preferred for withdrawals, it currently has {} lamports available. Please withdraw those before using other validator stake accounts.", preferred_withdraw_validator, u64::from(preferred_validator_info.active_stake_lamports)); + return Err(StakePoolError::IncorrectWithdrawVoteAddress.into()); + } + } + + let validator_stake_info = validator_list + .find_mut::(|x| { + ValidatorStakeInfo::memcmp_pubkey(x, &vote_account_address) + }) + .ok_or(StakePoolError::ValidatorNotFound)?; + + let withdraw_source = if has_active_stake { + // if there's any active stake, we must withdraw from an active + // stake account + check_validator_stake_address( + program_id, + stake_pool_info.key, + stake_split_from.key, + &vote_account_address, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + )?; + StakeWithdrawSource::Active + } else if has_transient_stake { + // if there's any transient stake, we must withdraw from there + check_transient_stake_address( + program_id, + stake_pool_info.key, + stake_split_from.key, + &vote_account_address, + validator_stake_info.transient_seed_suffix.into(), + )?; + StakeWithdrawSource::Transient + } else { + // if there's no active or transient stake, we can take the whole account + check_validator_stake_address( + program_id, + stake_pool_info.key, + stake_split_from.key, + &vote_account_address, + NonZeroU32::new(validator_stake_info.validator_seed_suffix.into()), + )?; + StakeWithdrawSource::ValidatorRemoval + }; + + if validator_stake_info.status != StakeStatus::Active.into() { + msg!("Validator is marked for removal and no longer allowing withdrawals"); + return Err(StakePoolError::ValidatorNotFound.into()); + } + + match withdraw_source { + StakeWithdrawSource::Active | StakeWithdrawSource::Transient => { + let remaining_lamports = stake_split_from + .lamports() + .saturating_sub(withdraw_lamports); + if remaining_lamports < required_lamports { + msg!("Attempting to withdraw {} lamports from validator account with {} stake lamports, {} must remain", withdraw_lamports, stake_split_from.lamports(), required_lamports); + return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into()); + } + } + StakeWithdrawSource::ValidatorRemoval => { + let split_from_lamports = stake_split_from.lamports(); + let upper_bound = split_from_lamports.saturating_add(lamports_per_pool_token); + if withdraw_lamports < split_from_lamports || withdraw_lamports > upper_bound { + msg!( + "Cannot withdraw a whole account worth {} lamports, \ + must withdraw at least {} lamports worth of pool tokens \ + with a margin of {} lamports", + withdraw_lamports, + split_from_lamports, + lamports_per_pool_token + ); + return Err(StakePoolError::StakeLamportsNotEqualToMinimum.into()); + } + // truncate the lamports down to the amount in the account + withdraw_lamports = split_from_lamports; + } + } + Some((validator_stake_info, withdraw_source)) + }; + + Self::token_burn( + token_program_info.clone(), + burn_from_pool_info.clone(), + pool_mint_info.clone(), + user_transfer_authority_info.clone(), + pool_tokens_burnt, + )?; + + Self::stake_split( + stake_pool_info.key, + stake_split_from.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + withdraw_lamports, + stake_split_to.clone(), + )?; + + Self::stake_authorize_signed( + stake_pool_info.key, + stake_split_to.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + user_stake_authority_info.key, + clock_info.clone(), + )?; + + if pool_tokens_fee > 0 { + Self::token_transfer( + token_program_info.clone(), + burn_from_pool_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + user_transfer_authority_info.clone(), + pool_tokens_fee, + decimals, + )?; + } + + stake_pool.pool_token_supply = stake_pool + .pool_token_supply + .checked_sub(pool_tokens_burnt) + .ok_or(StakePoolError::CalculationFailure)?; + stake_pool.total_lamports = stake_pool + .total_lamports + .checked_sub(withdraw_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + if let Some((validator_list_item, withdraw_source)) = validator_list_item_info { + match withdraw_source { + StakeWithdrawSource::Active => { + validator_list_item.active_stake_lamports = + u64::from(validator_list_item.active_stake_lamports) + .checked_sub(withdraw_lamports) + .ok_or(StakePoolError::CalculationFailure)? + .into() + } + StakeWithdrawSource::Transient => { + validator_list_item.transient_stake_lamports = + u64::from(validator_list_item.transient_stake_lamports) + .checked_sub(withdraw_lamports) + .ok_or(StakePoolError::CalculationFailure)? + .into() + } + StakeWithdrawSource::ValidatorRemoval => { + validator_list_item.active_stake_lamports = + u64::from(validator_list_item.active_stake_lamports) + .checked_sub(withdraw_lamports) + .ok_or(StakePoolError::CalculationFailure)? + .into(); + if u64::from(validator_list_item.active_stake_lamports) != 0 { + msg!("Attempting to remove a validator from the pool, but withdrawal leaves {} lamports, update the pool to merge any unaccounted lamports", + u64::from(validator_list_item.active_stake_lamports)); + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + // since we already checked that there's no transient stake, + // we can immediately set this as ready for removal + validator_list_item.status = StakeStatus::ReadyForRemoval.into(); + } + } + } + + Ok(()) + } + + /// Processes [WithdrawSol](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_withdraw_sol( + program_id: &Pubkey, + accounts: &[AccountInfo], + pool_tokens: u64, + minimum_lamports_out: Option, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let user_transfer_authority_info = next_account_info(account_info_iter)?; + let burn_from_pool_info = next_account_info(account_info_iter)?; + let reserve_stake_info = next_account_info(account_info_iter)?; + let destination_lamports_info = next_account_info(account_info_iter)?; + let manager_fee_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let clock_info = next_account_info(account_info_iter)?; + let stake_history_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + let token_program_info = next_account_info(account_info_iter)?; + let sol_withdraw_authority_info = next_account_info(account_info_iter); + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_sol_withdraw_authority(sol_withdraw_authority_info)?; + let decimals = stake_pool.check_mint(pool_mint_info)?; + stake_pool.check_reserve_stake(reserve_stake_info)?; + + if stake_pool.token_program_id != *token_program_info.key { + return Err(ProgramError::IncorrectProgramId); + } + check_stake_program(stake_program_info.key)?; + + if stake_pool.manager_fee_account != *manager_fee_info.key { + return Err(StakePoolError::InvalidFeeAccount.into()); + } + + // We want this to hold to ensure that withdraw_sol burns pool tokens + // at the right price + if stake_pool.last_update_epoch < Clock::get()?.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + // To prevent a faulty manager fee account from preventing withdrawals + // if the token program does not own the account, or if the account is not + // initialized + let pool_tokens_fee = if stake_pool.manager_fee_account == *burn_from_pool_info.key + || stake_pool.check_manager_fee_info(manager_fee_info).is_err() + { + 0 + } else { + stake_pool + .calc_pool_tokens_sol_withdrawal_fee(pool_tokens) + .ok_or(StakePoolError::CalculationFailure)? + }; + let pool_tokens_burnt = pool_tokens + .checked_sub(pool_tokens_fee) + .ok_or(StakePoolError::CalculationFailure)?; + + let withdraw_lamports = stake_pool + .calc_lamports_withdraw_amount(pool_tokens_burnt) + .ok_or(StakePoolError::CalculationFailure)?; + + if withdraw_lamports == 0 { + return Err(StakePoolError::WithdrawalTooSmall.into()); + } + + if let Some(minimum_lamports_out) = minimum_lamports_out { + if withdraw_lamports < minimum_lamports_out { + return Err(StakePoolError::ExceededSlippage.into()); + } + } + + let new_reserve_lamports = reserve_stake_info + .lamports() + .saturating_sub(withdraw_lamports); + let stake_state = try_from_slice_unchecked::( + &reserve_stake_info.data.borrow(), + )?; + if let stake::state::StakeStateV2::Initialized(meta) = stake_state { + let minimum_reserve_lamports = minimum_reserve_lamports(&meta); + if new_reserve_lamports < minimum_reserve_lamports { + msg!("Attempting to withdraw {} lamports, maximum possible SOL withdrawal is {} lamports", + withdraw_lamports, + reserve_stake_info.lamports().saturating_sub(minimum_reserve_lamports) + ); + return Err(StakePoolError::SolWithdrawalTooLarge.into()); + } + } else { + msg!("Reserve stake account not in intialized state"); + return Err(StakePoolError::WrongStakeStake.into()); + }; + + Self::token_burn( + token_program_info.clone(), + burn_from_pool_info.clone(), + pool_mint_info.clone(), + user_transfer_authority_info.clone(), + pool_tokens_burnt, + )?; + + if pool_tokens_fee > 0 { + Self::token_transfer( + token_program_info.clone(), + burn_from_pool_info.clone(), + pool_mint_info.clone(), + manager_fee_info.clone(), + user_transfer_authority_info.clone(), + pool_tokens_fee, + decimals, + )?; + } + + Self::stake_withdraw( + stake_pool_info.key, + reserve_stake_info.clone(), + withdraw_authority_info.clone(), + AUTHORITY_WITHDRAW, + stake_pool.stake_withdraw_bump_seed, + destination_lamports_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + withdraw_lamports, + )?; + + stake_pool.pool_token_supply = stake_pool + .pool_token_supply + .checked_sub(pool_tokens_burnt) + .ok_or(StakePoolError::CalculationFailure)?; + stake_pool.total_lamports = stake_pool + .total_lamports + .checked_sub(withdraw_lamports) + .ok_or(StakePoolError::CalculationFailure)?; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + + Ok(()) + } + + #[inline(never)] + fn process_create_pool_token_metadata( + program_id: &Pubkey, + accounts: &[AccountInfo], + name: String, + symbol: String, + uri: String, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let payer_info = next_account_info(account_info_iter)?; + let metadata_info = next_account_info(account_info_iter)?; + let mpl_token_metadata_program_info = next_account_info(account_info_iter)?; + let system_program_info = next_account_info(account_info_iter)?; + + if !payer_info.is_signer { + msg!("Payer did not sign metadata creation"); + return Err(StakePoolError::SignatureMissing.into()); + } + + check_system_program(system_program_info.key)?; + check_account_owner(payer_info, &system_program::id())?; + check_account_owner(stake_pool_info, program_id)?; + check_mpl_metadata_program(mpl_token_metadata_program_info.key)?; + + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_manager(manager_info)?; + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + stake_pool.check_mint(pool_mint_info)?; + check_mpl_metadata_account_address(metadata_info.key, &stake_pool.pool_mint)?; + + // Token mint authority for stake-pool token is stake-pool withdraw authority + let token_mint_authority = withdraw_authority_info; + + let new_metadata_instruction = create_metadata_accounts_v3( + *mpl_token_metadata_program_info.key, + *metadata_info.key, + *pool_mint_info.key, + *token_mint_authority.key, + *payer_info.key, + *token_mint_authority.key, + name, + symbol, + uri, + ); + + let (_, stake_withdraw_bump_seed) = + crate::find_withdraw_authority_program_address(program_id, stake_pool_info.key); + + let token_mint_authority_signer_seeds: &[&[_]] = &[ + stake_pool_info.key.as_ref(), + AUTHORITY_WITHDRAW, + &[stake_withdraw_bump_seed], + ]; + + invoke_signed( + &new_metadata_instruction, + &[ + metadata_info.clone(), + pool_mint_info.clone(), + withdraw_authority_info.clone(), + payer_info.clone(), + withdraw_authority_info.clone(), + system_program_info.clone(), + ], + &[token_mint_authority_signer_seeds], + )?; + + Ok(()) + } + + #[inline(never)] + fn process_update_pool_token_metadata( + program_id: &Pubkey, + accounts: &[AccountInfo], + name: String, + symbol: String, + uri: String, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + let withdraw_authority_info = next_account_info(account_info_iter)?; + let metadata_info = next_account_info(account_info_iter)?; + let mpl_token_metadata_program_info = next_account_info(account_info_iter)?; + + check_account_owner(stake_pool_info, program_id)?; + + check_mpl_metadata_program(mpl_token_metadata_program_info.key)?; + + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_manager(manager_info)?; + stake_pool.check_authority_withdraw( + withdraw_authority_info.key, + program_id, + stake_pool_info.key, + )?; + check_mpl_metadata_account_address(metadata_info.key, &stake_pool.pool_mint)?; + + // Token mint authority for stake-pool token is withdraw authority only + let token_mint_authority = withdraw_authority_info; + + let update_metadata_accounts_instruction = update_metadata_accounts_v2( + *mpl_token_metadata_program_info.key, + *metadata_info.key, + *token_mint_authority.key, + None, + Some(DataV2 { + name, + symbol, + uri, + seller_fee_basis_points: 0, + creators: None, + collection: None, + uses: None, + }), + None, + Some(true), + ); + + let (_, stake_withdraw_bump_seed) = + crate::find_withdraw_authority_program_address(program_id, stake_pool_info.key); + + let token_mint_authority_signer_seeds: &[&[_]] = &[ + stake_pool_info.key.as_ref(), + AUTHORITY_WITHDRAW, + &[stake_withdraw_bump_seed], + ]; + + invoke_signed( + &update_metadata_accounts_instruction, + &[metadata_info.clone(), withdraw_authority_info.clone()], + &[token_mint_authority_signer_seeds], + )?; + + Ok(()) + } + + /// Processes [SetManager](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_set_manager(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + let new_manager_info = next_account_info(account_info_iter)?; + let new_manager_fee_info = next_account_info(account_info_iter)?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + check_account_owner(new_manager_fee_info, &stake_pool.token_program_id)?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + stake_pool.check_manager(manager_info)?; + if !new_manager_info.is_signer { + msg!("New manager signature missing"); + return Err(StakePoolError::SignatureMissing.into()); + } + + stake_pool.check_manager_fee_info(new_manager_fee_info)?; + + stake_pool.manager = *new_manager_info.key; + stake_pool.manager_fee_account = *new_manager_fee_info.key; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + Ok(()) + } + + /// Processes [SetFee](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_set_fee( + program_id: &Pubkey, + accounts: &[AccountInfo], + fee: FeeType, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + let clock = Clock::get()?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + stake_pool.check_manager(manager_info)?; + + if fee.can_only_change_next_epoch() && stake_pool.last_update_epoch < clock.epoch { + return Err(StakePoolError::StakeListAndPoolOutOfDate.into()); + } + + fee.check_too_high()?; + stake_pool.update_fee(&fee)?; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + Ok(()) + } + + /// Processes [SetStaker](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_set_staker(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let set_staker_authority_info = next_account_info(account_info_iter)?; + let new_staker_info = next_account_info(account_info_iter)?; + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + + let staker_signed = stake_pool.check_staker(set_staker_authority_info); + let manager_signed = stake_pool.check_manager(set_staker_authority_info); + if staker_signed.is_err() && manager_signed.is_err() { + return Err(StakePoolError::SignatureMissing.into()); + } + stake_pool.staker = *new_staker_info.key; + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + Ok(()) + } + + /// Processes [SetFundingAuthority](enum.Instruction.html). + #[inline(never)] // needed to avoid stack size violation + fn process_set_funding_authority( + program_id: &Pubkey, + accounts: &[AccountInfo], + funding_type: FundingType, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let stake_pool_info = next_account_info(account_info_iter)?; + let manager_info = next_account_info(account_info_iter)?; + + let new_authority = next_account_info(account_info_iter) + .ok() + .map(|new_authority_account_info| *new_authority_account_info.key); + + check_account_owner(stake_pool_info, program_id)?; + let mut stake_pool = try_from_slice_unchecked::(&stake_pool_info.data.borrow())?; + if !stake_pool.is_valid() { + return Err(StakePoolError::InvalidState.into()); + } + stake_pool.check_manager(manager_info)?; + match funding_type { + FundingType::StakeDeposit => { + stake_pool.stake_deposit_authority = new_authority.unwrap_or( + find_deposit_authority_program_address(program_id, stake_pool_info.key).0, + ); + } + FundingType::SolDeposit => stake_pool.sol_deposit_authority = new_authority, + FundingType::SolWithdraw => stake_pool.sol_withdraw_authority = new_authority, + } + borsh::to_writer(&mut stake_pool_info.data.borrow_mut()[..], &stake_pool)?; + Ok(()) + } + + /// Processes [Instruction](enum.Instruction.html). + pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = StakePoolInstruction::try_from_slice(input)?; + match instruction { + StakePoolInstruction::Initialize { + fee, + withdrawal_fee, + deposit_fee, + referral_fee, + max_validators, + } => { + msg!("Instruction: Initialize stake pool"); + Self::process_initialize( + program_id, + accounts, + fee, + withdrawal_fee, + deposit_fee, + referral_fee, + max_validators, + ) + } + StakePoolInstruction::AddValidatorToPool(seed) => { + msg!("Instruction: AddValidatorToPool"); + Self::process_add_validator_to_pool(program_id, accounts, seed) + } + StakePoolInstruction::RemoveValidatorFromPool => { + msg!("Instruction: RemoveValidatorFromPool"); + Self::process_remove_validator_from_pool(program_id, accounts) + } + StakePoolInstruction::DecreaseValidatorStake { + lamports, + transient_stake_seed, + } => { + msg!("Instruction: DecreaseValidatorStake"); + msg!("NOTE: This instruction is deprecated, please use `DecreaseValidatorStakeWithReserve`"); + Self::process_decrease_validator_stake( + program_id, + accounts, + lamports, + transient_stake_seed, + None, + false, + ) + } + StakePoolInstruction::DecreaseValidatorStakeWithReserve { + lamports, + transient_stake_seed, + } => { + msg!("Instruction: DecreaseValidatorStakeWithReserve"); + Self::process_decrease_validator_stake( + program_id, + accounts, + lamports, + transient_stake_seed, + None, + true, + ) + } + StakePoolInstruction::DecreaseAdditionalValidatorStake { + lamports, + transient_stake_seed, + ephemeral_stake_seed, + } => { + msg!("Instruction: DecreaseAdditionalValidatorStake"); + Self::process_decrease_validator_stake( + program_id, + accounts, + lamports, + transient_stake_seed, + Some(ephemeral_stake_seed), + true, + ) + } + StakePoolInstruction::IncreaseValidatorStake { + lamports, + transient_stake_seed, + } => { + msg!("Instruction: IncreaseValidatorStake"); + Self::process_increase_validator_stake( + program_id, + accounts, + lamports, + transient_stake_seed, + None, + ) + } + StakePoolInstruction::IncreaseAdditionalValidatorStake { + lamports, + transient_stake_seed, + ephemeral_stake_seed, + } => { + msg!("Instruction: IncreaseAdditionalValidatorStake"); + Self::process_increase_validator_stake( + program_id, + accounts, + lamports, + transient_stake_seed, + Some(ephemeral_stake_seed), + ) + } + StakePoolInstruction::SetPreferredValidator { + validator_type, + validator_vote_address, + } => { + msg!("Instruction: SetPreferredValidator"); + Self::process_set_preferred_validator( + program_id, + accounts, + validator_type, + validator_vote_address, + ) + } + StakePoolInstruction::UpdateValidatorListBalance { + start_index, + no_merge, + } => { + msg!("Instruction: UpdateValidatorListBalance"); + Self::process_update_validator_list_balance( + program_id, + accounts, + start_index, + no_merge, + ) + } + StakePoolInstruction::UpdateStakePoolBalance => { + msg!("Instruction: UpdateStakePoolBalance"); + Self::process_update_stake_pool_balance(program_id, accounts) + } + StakePoolInstruction::CleanupRemovedValidatorEntries => { + msg!("Instruction: CleanupRemovedValidatorEntries"); + Self::process_cleanup_removed_validator_entries(program_id, accounts) + } + StakePoolInstruction::DepositStake => { + msg!("Instruction: DepositStake"); + Self::process_deposit_stake(program_id, accounts, None) + } + StakePoolInstruction::WithdrawStake(amount) => { + msg!("Instruction: WithdrawStake"); + Self::process_withdraw_stake(program_id, accounts, amount, None) + } + StakePoolInstruction::SetFee { fee } => { + msg!("Instruction: SetFee"); + Self::process_set_fee(program_id, accounts, fee) + } + StakePoolInstruction::SetManager => { + msg!("Instruction: SetManager"); + Self::process_set_manager(program_id, accounts) + } + StakePoolInstruction::SetStaker => { + msg!("Instruction: SetStaker"); + Self::process_set_staker(program_id, accounts) + } + StakePoolInstruction::SetFundingAuthority(funding_type) => { + msg!("Instruction: SetFundingAuthority"); + Self::process_set_funding_authority(program_id, accounts, funding_type) + } + StakePoolInstruction::DepositSol(lamports) => { + msg!("Instruction: DepositSol"); + Self::process_deposit_sol(program_id, accounts, lamports, None) + } + StakePoolInstruction::WithdrawSol(pool_tokens) => { + msg!("Instruction: WithdrawSol"); + Self::process_withdraw_sol(program_id, accounts, pool_tokens, None) + } + StakePoolInstruction::CreateTokenMetadata { name, symbol, uri } => { + msg!("Instruction: CreateTokenMetadata"); + Self::process_create_pool_token_metadata(program_id, accounts, name, symbol, uri) + } + StakePoolInstruction::UpdateTokenMetadata { name, symbol, uri } => { + msg!("Instruction: UpdateTokenMetadata"); + Self::process_update_pool_token_metadata(program_id, accounts, name, symbol, uri) + } + #[allow(deprecated)] + StakePoolInstruction::Redelegate { .. } => { + msg!("Instruction: Redelegate will not be enabled"); + Err(ProgramError::InvalidInstructionData) + } + StakePoolInstruction::DepositStakeWithSlippage { + minimum_pool_tokens_out, + } => { + msg!("Instruction: DepositStakeWithSlippage"); + Self::process_deposit_stake(program_id, accounts, Some(minimum_pool_tokens_out)) + } + StakePoolInstruction::WithdrawStakeWithSlippage { + pool_tokens_in, + minimum_lamports_out, + } => { + msg!("Instruction: WithdrawStakeWithSlippage"); + Self::process_withdraw_stake( + program_id, + accounts, + pool_tokens_in, + Some(minimum_lamports_out), + ) + } + StakePoolInstruction::DepositSolWithSlippage { + lamports_in, + minimum_pool_tokens_out, + } => { + msg!("Instruction: DepositSolWithSlippage"); + Self::process_deposit_sol( + program_id, + accounts, + lamports_in, + Some(minimum_pool_tokens_out), + ) + } + StakePoolInstruction::WithdrawSolWithSlippage { + pool_tokens_in, + minimum_lamports_out, + } => { + msg!("Instruction: WithdrawSolWithSlippage"); + Self::process_withdraw_sol( + program_id, + accounts, + pool_tokens_in, + Some(minimum_lamports_out), + ) + } + } + } +} + +impl PrintProgramError for StakePoolError { + fn print(&self) + where + E: 'static + std::error::Error + DecodeError + PrintProgramError + FromPrimitive, + { + match self { + StakePoolError::AlreadyInUse => msg!("Error: The account cannot be initialized because it is already being used"), + StakePoolError::InvalidProgramAddress => msg!("Error: The program address provided doesn't match the value generated by the program"), + StakePoolError::InvalidState => msg!("Error: The stake pool state is invalid"), + StakePoolError::CalculationFailure => msg!("Error: The calculation failed"), + StakePoolError::FeeTooHigh => msg!("Error: Stake pool fee > 1"), + StakePoolError::WrongAccountMint => msg!("Error: Token account is associated with the wrong mint"), + StakePoolError::WrongManager => msg!("Error: Wrong pool manager account"), + StakePoolError::SignatureMissing => msg!("Error: Required signature is missing"), + StakePoolError::InvalidValidatorStakeList => msg!("Error: Invalid validator stake list account"), + StakePoolError::InvalidFeeAccount => msg!("Error: Invalid manager fee account"), + StakePoolError::WrongPoolMint => msg!("Error: Specified pool mint account is wrong"), + StakePoolError::WrongStakeStake => msg!("Error: Stake account is not in the state expected by the program"), + StakePoolError::UserStakeNotActive => msg!("Error: User stake is not active"), + StakePoolError::ValidatorAlreadyAdded => msg!("Error: Stake account voting for this validator already exists in the pool"), + StakePoolError::ValidatorNotFound => msg!("Error: Stake account for this validator not found in the pool"), + StakePoolError::InvalidStakeAccountAddress => msg!("Error: Stake account address not properly derived from the validator address"), + StakePoolError::StakeListOutOfDate => msg!("Error: Identify validator stake accounts with old balances and update them"), + StakePoolError::StakeListAndPoolOutOfDate => msg!("Error: First update old validator stake account balances and then pool stake balance"), + StakePoolError::UnknownValidatorStakeAccount => { + msg!("Error: Validator stake account is not found in the list storage") + } + StakePoolError::WrongMintingAuthority => msg!("Error: Wrong minting authority set for mint pool account"), + StakePoolError::UnexpectedValidatorListAccountSize=> msg!("Error: The size of the given validator stake list does match the expected amount"), + StakePoolError::WrongStaker=> msg!("Error: Wrong pool staker account"), + StakePoolError::NonZeroPoolTokenSupply => msg!("Error: Pool token supply is not zero on initialization"), + StakePoolError::StakeLamportsNotEqualToMinimum => msg!("Error: The lamports in the validator stake account is not equal to the minimum"), + StakePoolError::IncorrectDepositVoteAddress => msg!("Error: The provided deposit stake account is not delegated to the preferred deposit vote account"), + StakePoolError::IncorrectWithdrawVoteAddress => msg!("Error: The provided withdraw stake account is not the preferred deposit vote account"), + StakePoolError::InvalidMintFreezeAuthority => msg!("Error: The mint has an invalid freeze authority"), + StakePoolError::FeeIncreaseTooHigh => msg!("Error: The fee cannot increase by a factor exceeding the stipulated ratio"), + StakePoolError::WithdrawalTooSmall => msg!("Error: Not enough pool tokens provided to withdraw 1-lamport stake"), + StakePoolError::DepositTooSmall => msg!("Error: Not enough lamports provided for deposit to result in one pool token"), + StakePoolError::InvalidStakeDepositAuthority => msg!("Error: Provided stake deposit authority does not match the program's"), + StakePoolError::InvalidSolDepositAuthority => msg!("Error: Provided sol deposit authority does not match the program's"), + StakePoolError::InvalidPreferredValidator => msg!("Error: Provided preferred validator is invalid"), + StakePoolError::TransientAccountInUse => msg!("Error: Provided validator stake account already has a transient stake account in use"), + StakePoolError::InvalidSolWithdrawAuthority => msg!("Error: Provided sol withdraw authority does not match the program's"), + StakePoolError::SolWithdrawalTooLarge => msg!("Error: Too much SOL withdrawn from the stake pool's reserve account"), + StakePoolError::InvalidMetadataAccount => msg!("Error: Metadata account derived from pool mint account does not match the one passed to program"), + StakePoolError::UnsupportedMintExtension => msg!("Error: mint has an unsupported extension"), + StakePoolError::UnsupportedFeeAccountExtension => msg!("Error: fee account has an unsupported extension"), + StakePoolError::ExceededSlippage => msg!("Error: instruction exceeds desired slippage limit"), + StakePoolError::IncorrectMintDecimals => msg!("Error: Provided mint does not have 9 decimals to match SOL"), + StakePoolError::ReserveDepleted => msg!("Error: Pool reserve does not have enough lamports to fund rent-exempt reserve in split destination. Deposit more SOL in reserve, or pre-fund split destination with the rent-exempt reserve for a stake account."), + StakePoolError::MissingRequiredSysvar => msg!("Missing required sysvar account"), + } + } +} diff --git a/program/src/state.rs b/program/src/state.rs new file mode 100644 index 00000000..831c1877 --- /dev/null +++ b/program/src/state.rs @@ -0,0 +1,1465 @@ +//! State transition types + +use { + crate::{ + big_vec::BigVec, error::StakePoolError, MAX_WITHDRAWAL_FEE_INCREASE, + WITHDRAWAL_BASELINE_FEE, + }, + borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, + bytemuck::{Pod, Zeroable}, + num_derive::{FromPrimitive, ToPrimitive}, + num_traits::{FromPrimitive, ToPrimitive}, + solana_program::{ + account_info::AccountInfo, + borsh1::get_instance_packed_len, + msg, + program_error::ProgramError, + program_memory::sol_memcmp, + program_pack::{Pack, Sealed}, + pubkey::{Pubkey, PUBKEY_BYTES}, + stake::state::Lockup, + }, + spl_pod::primitives::{PodU32, PodU64}, + spl_token_2022::{ + extension::{BaseStateWithExtensions, ExtensionType, StateWithExtensions}, + state::{Account, AccountState, Mint}, + }, + std::{borrow::Borrow, convert::TryFrom, fmt, matches}, +}; + +/// Enum representing the account type managed by the program +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub enum AccountType { + /// If the account has not been initialized, the enum will be 0 + #[default] + Uninitialized, + /// Stake pool + StakePool, + /// Validator stake list + ValidatorList, +} + +/// Initialized program details. +#[repr(C)] +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct StakePool { + /// Account type, must be StakePool currently + pub account_type: AccountType, + + /// Manager authority, allows for updating the staker, manager, and fee + /// account + pub manager: Pubkey, + + /// Staker authority, allows for adding and removing validators, and + /// managing stake distribution + pub staker: Pubkey, + + /// Stake deposit authority + /// + /// If a depositor pubkey is specified on initialization, then deposits must + /// be signed by this authority. If no deposit authority is specified, + /// then the stake pool will default to the result of: + /// `Pubkey::find_program_address( + /// &[&stake_pool_address.as_ref(), b"deposit"], + /// program_id, + /// )` + pub stake_deposit_authority: Pubkey, + + /// Stake withdrawal authority bump seed + /// for `create_program_address(&[state::StakePool account, "withdrawal"])` + pub stake_withdraw_bump_seed: u8, + + /// Validator stake list storage account + pub validator_list: Pubkey, + + /// Reserve stake account, holds deactivated stake + pub reserve_stake: Pubkey, + + /// Pool Mint + pub pool_mint: Pubkey, + + /// Manager fee account + pub manager_fee_account: Pubkey, + + /// Pool token program id + pub token_program_id: Pubkey, + + /// Total stake under management. + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + pub total_lamports: u64, + + /// Total supply of pool tokens (should always match the supply in the Pool + /// Mint) + pub pool_token_supply: u64, + + /// Last epoch the `total_lamports` field was updated + pub last_update_epoch: u64, + + /// Lockup that all stakes in the pool must have + pub lockup: Lockup, + + /// Fee taken as a proportion of rewards each epoch + pub epoch_fee: Fee, + + /// Fee for next epoch + pub next_epoch_fee: FutureEpoch, + + /// Preferred deposit validator vote account pubkey + pub preferred_deposit_validator_vote_address: Option, + + /// Preferred withdraw validator vote account pubkey + pub preferred_withdraw_validator_vote_address: Option, + + /// Fee assessed on stake deposits + pub stake_deposit_fee: Fee, + + /// Fee assessed on withdrawals + pub stake_withdrawal_fee: Fee, + + /// Future stake withdrawal fee, to be set for the following epoch + pub next_stake_withdrawal_fee: FutureEpoch, + + /// Fees paid out to referrers on referred stake deposits. + /// Expressed as a percentage (0 - 100) of deposit fees. + /// i.e. `stake_deposit_fee`% of stake deposited is collected as deposit + /// fees for every deposit and `stake_referral_fee`% of the collected + /// stake deposit fees is paid out to the referrer + pub stake_referral_fee: u8, + + /// Toggles whether the `DepositSol` instruction requires a signature from + /// this `sol_deposit_authority` + pub sol_deposit_authority: Option, + + /// Fee assessed on SOL deposits + pub sol_deposit_fee: Fee, + + /// Fees paid out to referrers on referred SOL deposits. + /// Expressed as a percentage (0 - 100) of SOL deposit fees. + /// i.e. `sol_deposit_fee`% of SOL deposited is collected as deposit fees + /// for every deposit and `sol_referral_fee`% of the collected SOL + /// deposit fees is paid out to the referrer + pub sol_referral_fee: u8, + + /// Toggles whether the `WithdrawSol` instruction requires a signature from + /// the `deposit_authority` + pub sol_withdraw_authority: Option, + + /// Fee assessed on SOL withdrawals + pub sol_withdrawal_fee: Fee, + + /// Future SOL withdrawal fee, to be set for the following epoch + pub next_sol_withdrawal_fee: FutureEpoch, + + /// Last epoch's total pool tokens, used only for APR estimation + pub last_epoch_pool_token_supply: u64, + + /// Last epoch's total lamports, used only for APR estimation + pub last_epoch_total_lamports: u64, +} +impl StakePool { + /// calculate the pool tokens that should be minted for a deposit of + /// `stake_lamports` + #[inline] + pub fn calc_pool_tokens_for_deposit(&self, stake_lamports: u64) -> Option { + if self.total_lamports == 0 || self.pool_token_supply == 0 { + return Some(stake_lamports); + } + u64::try_from( + (stake_lamports as u128) + .checked_mul(self.pool_token_supply as u128)? + .checked_div(self.total_lamports as u128)?, + ) + .ok() + } + + /// calculate lamports amount on withdrawal + #[inline] + pub fn calc_lamports_withdraw_amount(&self, pool_tokens: u64) -> Option { + // `checked_div` returns `None` for a 0 quotient result, but in this + // case, a return of 0 is valid for small amounts of pool tokens. So + // we check for that separately + let numerator = (pool_tokens as u128).checked_mul(self.total_lamports as u128)?; + let denominator = self.pool_token_supply as u128; + if numerator < denominator || denominator == 0 { + Some(0) + } else { + u64::try_from(numerator.checked_div(denominator)?).ok() + } + } + + /// calculate pool tokens to be deducted as withdrawal fees + #[inline] + pub fn calc_pool_tokens_stake_withdrawal_fee(&self, pool_tokens: u64) -> Option { + u64::try_from(self.stake_withdrawal_fee.apply(pool_tokens)?).ok() + } + + /// calculate pool tokens to be deducted as withdrawal fees + #[inline] + pub fn calc_pool_tokens_sol_withdrawal_fee(&self, pool_tokens: u64) -> Option { + u64::try_from(self.sol_withdrawal_fee.apply(pool_tokens)?).ok() + } + + /// calculate pool tokens to be deducted as stake deposit fees + #[inline] + pub fn calc_pool_tokens_stake_deposit_fee(&self, pool_tokens_minted: u64) -> Option { + u64::try_from(self.stake_deposit_fee.apply(pool_tokens_minted)?).ok() + } + + /// calculate pool tokens to be deducted from deposit fees as referral fees + #[inline] + pub fn calc_pool_tokens_stake_referral_fee(&self, stake_deposit_fee: u64) -> Option { + u64::try_from( + (stake_deposit_fee as u128) + .checked_mul(self.stake_referral_fee as u128)? + .checked_div(100u128)?, + ) + .ok() + } + + /// calculate pool tokens to be deducted as SOL deposit fees + #[inline] + pub fn calc_pool_tokens_sol_deposit_fee(&self, pool_tokens_minted: u64) -> Option { + u64::try_from(self.sol_deposit_fee.apply(pool_tokens_minted)?).ok() + } + + /// calculate pool tokens to be deducted from SOL deposit fees as referral + /// fees + #[inline] + pub fn calc_pool_tokens_sol_referral_fee(&self, sol_deposit_fee: u64) -> Option { + u64::try_from( + (sol_deposit_fee as u128) + .checked_mul(self.sol_referral_fee as u128)? + .checked_div(100u128)?, + ) + .ok() + } + + /// Calculate the fee in pool tokens that goes to the manager + /// + /// This function assumes that `reward_lamports` has not already been added + /// to the stake pool's `total_lamports` + #[inline] + pub fn calc_epoch_fee_amount(&self, reward_lamports: u64) -> Option { + if reward_lamports == 0 { + return Some(0); + } + let total_lamports = (self.total_lamports as u128).checked_add(reward_lamports as u128)?; + let fee_lamports = self.epoch_fee.apply(reward_lamports)?; + if total_lamports == fee_lamports || self.pool_token_supply == 0 { + Some(reward_lamports) + } else { + u64::try_from( + (self.pool_token_supply as u128) + .checked_mul(fee_lamports)? + .checked_div(total_lamports.checked_sub(fee_lamports)?)?, + ) + .ok() + } + } + + /// Get the current value of pool tokens, rounded up + #[inline] + pub fn get_lamports_per_pool_token(&self) -> Option { + self.total_lamports + .checked_add(self.pool_token_supply)? + .checked_sub(1)? + .checked_div(self.pool_token_supply) + } + + /// Checks that the withdraw or deposit authority is valid + fn check_program_derived_authority( + authority_address: &Pubkey, + program_id: &Pubkey, + stake_pool_address: &Pubkey, + authority_seed: &[u8], + bump_seed: u8, + ) -> Result<(), ProgramError> { + let expected_address = Pubkey::create_program_address( + &[stake_pool_address.as_ref(), authority_seed, &[bump_seed]], + program_id, + )?; + + if *authority_address == expected_address { + Ok(()) + } else { + msg!( + "Incorrect authority provided, expected {}, received {}", + expected_address, + authority_address + ); + Err(StakePoolError::InvalidProgramAddress.into()) + } + } + + /// Check if the manager fee info is a valid token program account + /// capable of receiving tokens from the mint. + pub(crate) fn check_manager_fee_info( + &self, + manager_fee_info: &AccountInfo, + ) -> Result<(), ProgramError> { + let account_data = manager_fee_info.try_borrow_data()?; + let token_account = StateWithExtensions::::unpack(&account_data)?; + if manager_fee_info.owner != &self.token_program_id + || token_account.base.state != AccountState::Initialized + || token_account.base.mint != self.pool_mint + { + msg!("Manager fee account is not owned by token program, is not initialized, or does not match stake pool's mint"); + return Err(StakePoolError::InvalidFeeAccount.into()); + } + let extensions = token_account.get_extension_types()?; + if extensions + .iter() + .any(|x| !is_extension_supported_for_fee_account(x)) + { + return Err(StakePoolError::UnsupportedFeeAccountExtension.into()); + } + Ok(()) + } + + /// Checks that the withdraw authority is valid + #[inline] + pub(crate) fn check_authority_withdraw( + &self, + withdraw_authority: &Pubkey, + program_id: &Pubkey, + stake_pool_address: &Pubkey, + ) -> Result<(), ProgramError> { + Self::check_program_derived_authority( + withdraw_authority, + program_id, + stake_pool_address, + crate::AUTHORITY_WITHDRAW, + self.stake_withdraw_bump_seed, + ) + } + /// Checks that the deposit authority is valid + #[inline] + pub(crate) fn check_stake_deposit_authority( + &self, + stake_deposit_authority: &Pubkey, + ) -> Result<(), ProgramError> { + if self.stake_deposit_authority == *stake_deposit_authority { + Ok(()) + } else { + Err(StakePoolError::InvalidStakeDepositAuthority.into()) + } + } + + /// Checks that the deposit authority is valid + /// Does nothing if `sol_deposit_authority` is currently not set + #[inline] + pub(crate) fn check_sol_deposit_authority( + &self, + maybe_sol_deposit_authority: Result<&AccountInfo, ProgramError>, + ) -> Result<(), ProgramError> { + if let Some(auth) = self.sol_deposit_authority { + let sol_deposit_authority = maybe_sol_deposit_authority?; + if auth != *sol_deposit_authority.key { + msg!("Expected {}, received {}", auth, sol_deposit_authority.key); + return Err(StakePoolError::InvalidSolDepositAuthority.into()); + } + if !sol_deposit_authority.is_signer { + msg!("SOL Deposit authority signature missing"); + return Err(StakePoolError::SignatureMissing.into()); + } + } + Ok(()) + } + + /// Checks that the sol withdraw authority is valid + /// Does nothing if `sol_withdraw_authority` is currently not set + #[inline] + pub(crate) fn check_sol_withdraw_authority( + &self, + maybe_sol_withdraw_authority: Result<&AccountInfo, ProgramError>, + ) -> Result<(), ProgramError> { + if let Some(auth) = self.sol_withdraw_authority { + let sol_withdraw_authority = maybe_sol_withdraw_authority?; + if auth != *sol_withdraw_authority.key { + return Err(StakePoolError::InvalidSolWithdrawAuthority.into()); + } + if !sol_withdraw_authority.is_signer { + msg!("SOL withdraw authority signature missing"); + return Err(StakePoolError::SignatureMissing.into()); + } + } + Ok(()) + } + + /// Check mint is correct + #[inline] + pub(crate) fn check_mint(&self, mint_info: &AccountInfo) -> Result { + if *mint_info.key != self.pool_mint { + Err(StakePoolError::WrongPoolMint.into()) + } else { + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + Ok(mint.base.decimals) + } + } + + /// Check manager validity and signature + pub(crate) fn check_manager(&self, manager_info: &AccountInfo) -> Result<(), ProgramError> { + if *manager_info.key != self.manager { + msg!( + "Incorrect manager provided, expected {}, received {}", + self.manager, + manager_info.key + ); + return Err(StakePoolError::WrongManager.into()); + } + if !manager_info.is_signer { + msg!("Manager signature missing"); + return Err(StakePoolError::SignatureMissing.into()); + } + Ok(()) + } + + /// Check staker validity and signature + pub(crate) fn check_staker(&self, staker_info: &AccountInfo) -> Result<(), ProgramError> { + if *staker_info.key != self.staker { + msg!( + "Incorrect staker provided, expected {}, received {}", + self.staker, + staker_info.key + ); + return Err(StakePoolError::WrongStaker.into()); + } + if !staker_info.is_signer { + msg!("Staker signature missing"); + return Err(StakePoolError::SignatureMissing.into()); + } + Ok(()) + } + + /// Check the validator list is valid + pub fn check_validator_list( + &self, + validator_list_info: &AccountInfo, + ) -> Result<(), ProgramError> { + if *validator_list_info.key != self.validator_list { + msg!( + "Invalid validator list provided, expected {}, received {}", + self.validator_list, + validator_list_info.key + ); + Err(StakePoolError::InvalidValidatorStakeList.into()) + } else { + Ok(()) + } + } + + /// Check the reserve stake is valid + pub fn check_reserve_stake( + &self, + reserve_stake_info: &AccountInfo, + ) -> Result<(), ProgramError> { + if *reserve_stake_info.key != self.reserve_stake { + msg!( + "Invalid reserve stake provided, expected {}, received {}", + self.reserve_stake, + reserve_stake_info.key + ); + Err(StakePoolError::InvalidProgramAddress.into()) + } else { + Ok(()) + } + } + + /// Check if StakePool is actually initialized as a stake pool + pub fn is_valid(&self) -> bool { + self.account_type == AccountType::StakePool + } + + /// Check if StakePool is currently uninitialized + pub fn is_uninitialized(&self) -> bool { + self.account_type == AccountType::Uninitialized + } + + /// Updates one of the StakePool's fees. + pub fn update_fee(&mut self, fee: &FeeType) -> Result<(), StakePoolError> { + match fee { + FeeType::SolReferral(new_fee) => self.sol_referral_fee = *new_fee, + FeeType::StakeReferral(new_fee) => self.stake_referral_fee = *new_fee, + FeeType::Epoch(new_fee) => self.next_epoch_fee = FutureEpoch::new(*new_fee), + FeeType::StakeWithdrawal(new_fee) => { + new_fee.check_withdrawal(&self.stake_withdrawal_fee)?; + self.next_stake_withdrawal_fee = FutureEpoch::new(*new_fee) + } + FeeType::SolWithdrawal(new_fee) => { + new_fee.check_withdrawal(&self.sol_withdrawal_fee)?; + self.next_sol_withdrawal_fee = FutureEpoch::new(*new_fee) + } + FeeType::SolDeposit(new_fee) => self.sol_deposit_fee = *new_fee, + FeeType::StakeDeposit(new_fee) => self.stake_deposit_fee = *new_fee, + }; + Ok(()) + } +} + +/// Checks if the given extension is supported for the stake pool mint +pub fn is_extension_supported_for_mint(extension_type: &ExtensionType) -> bool { + const SUPPORTED_EXTENSIONS: [ExtensionType; 8] = [ + ExtensionType::Uninitialized, + ExtensionType::TransferFeeConfig, + ExtensionType::ConfidentialTransferMint, + ExtensionType::ConfidentialTransferFeeConfig, + ExtensionType::DefaultAccountState, // ok, but a freeze authority is not + ExtensionType::InterestBearingConfig, + ExtensionType::MetadataPointer, + ExtensionType::TokenMetadata, + ]; + if !SUPPORTED_EXTENSIONS.contains(extension_type) { + msg!( + "Stake pool mint account cannot have the {:?} extension", + extension_type + ); + false + } else { + true + } +} + +/// Checks if the given extension is supported for the stake pool's fee account +pub fn is_extension_supported_for_fee_account(extension_type: &ExtensionType) -> bool { + // Note: this does not include the `ConfidentialTransferAccount` extension + // because it is possible to block non-confidential transfers with the + // extension enabled. + const SUPPORTED_EXTENSIONS: [ExtensionType; 4] = [ + ExtensionType::Uninitialized, + ExtensionType::TransferFeeAmount, + ExtensionType::ImmutableOwner, + ExtensionType::CpiGuard, + ]; + if !SUPPORTED_EXTENSIONS.contains(extension_type) { + msg!("Fee account cannot have the {:?} extension", extension_type); + false + } else { + true + } +} + +/// Storage list for all validator stake accounts in the pool. +#[repr(C)] +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct ValidatorList { + /// Data outside of the validator list, separated out for cheaper + /// deserializations + pub header: ValidatorListHeader, + + /// List of stake info for each validator in the pool + pub validators: Vec, +} + +/// Helper type to deserialize just the start of a ValidatorList +#[repr(C)] +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct ValidatorListHeader { + /// Account type, must be ValidatorList currently + pub account_type: AccountType, + + /// Maximum allowable number of validators + pub max_validators: u32, +} + +/// Status of the stake account in the validator list, for accounting +#[derive( + ToPrimitive, + FromPrimitive, + Copy, + Clone, + Debug, + PartialEq, + BorshDeserialize, + BorshSerialize, + BorshSchema, +)] +pub enum StakeStatus { + /// Stake account is active, there may be a transient stake as well + Active, + /// Only transient stake account exists, when a transient stake is + /// deactivating during validator removal + DeactivatingTransient, + /// No more validator stake accounts exist, entry ready for removal during + /// `UpdateStakePoolBalance` + ReadyForRemoval, + /// Only the validator stake account is deactivating, no transient stake + /// account exists + DeactivatingValidator, + /// Both the transient and validator stake account are deactivating, when + /// a validator is removed with a transient stake active + DeactivatingAll, +} +impl Default for StakeStatus { + fn default() -> Self { + Self::Active + } +} + +/// Wrapper struct that can be `Pod`, containing a byte that *should* be a valid +/// `StakeStatus` underneath. +#[repr(transparent)] +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Pod, + Zeroable, + BorshDeserialize, + BorshSerialize, + BorshSchema, +)] +pub struct PodStakeStatus(u8); +impl PodStakeStatus { + /// Downgrade the status towards ready for removal by removing the validator + /// stake + pub fn remove_validator_stake(&mut self) -> Result<(), ProgramError> { + let status = StakeStatus::try_from(*self)?; + let new_self = match status { + StakeStatus::Active + | StakeStatus::DeactivatingTransient + | StakeStatus::ReadyForRemoval => status, + StakeStatus::DeactivatingAll => StakeStatus::DeactivatingTransient, + StakeStatus::DeactivatingValidator => StakeStatus::ReadyForRemoval, + }; + *self = new_self.into(); + Ok(()) + } + /// Downgrade the status towards ready for removal by removing the transient + /// stake + pub fn remove_transient_stake(&mut self) -> Result<(), ProgramError> { + let status = StakeStatus::try_from(*self)?; + let new_self = match status { + StakeStatus::Active + | StakeStatus::DeactivatingValidator + | StakeStatus::ReadyForRemoval => status, + StakeStatus::DeactivatingAll => StakeStatus::DeactivatingValidator, + StakeStatus::DeactivatingTransient => StakeStatus::ReadyForRemoval, + }; + *self = new_self.into(); + Ok(()) + } +} +impl TryFrom for StakeStatus { + type Error = ProgramError; + fn try_from(pod: PodStakeStatus) -> Result { + FromPrimitive::from_u8(pod.0).ok_or(ProgramError::InvalidAccountData) + } +} +impl From for PodStakeStatus { + fn from(status: StakeStatus) -> Self { + // unwrap is safe here because the variants of `StakeStatus` fit very + // comfortably within a `u8` + PodStakeStatus(status.to_u8().unwrap()) + } +} + +/// Withdrawal type, figured out during process_withdraw_stake +#[derive(Debug, PartialEq)] +pub(crate) enum StakeWithdrawSource { + /// Some of an active stake account, but not all + Active, + /// Some of a transient stake account + Transient, + /// Take a whole validator stake account + ValidatorRemoval, +} + +/// Information about a validator in the pool +/// +/// NOTE: ORDER IS VERY IMPORTANT HERE, PLEASE DO NOT RE-ORDER THE FIELDS UNLESS +/// THERE'S AN EXTREMELY GOOD REASON. +/// +/// To save on BPF instructions, the serialized bytes are reinterpreted with a +/// bytemuck transmute, which means that this structure cannot have any +/// undeclared alignment-padding in its representation. +#[repr(C)] +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Pod, + Zeroable, + BorshDeserialize, + BorshSerialize, + BorshSchema, +)] +pub struct ValidatorStakeInfo { + /// Amount of lamports on the validator stake account, including rent + /// + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + pub active_stake_lamports: PodU64, + + /// Amount of transient stake delegated to this validator + /// + /// Note that if `last_update_epoch` does not match the current epoch then + /// this field may not be accurate + pub transient_stake_lamports: PodU64, + + /// Last epoch the active and transient stake lamports fields were updated + pub last_update_epoch: PodU64, + + /// Transient account seed suffix, used to derive the transient stake + /// account address + pub transient_seed_suffix: PodU64, + + /// Unused space, initially meant to specify the end of seed suffixes + pub unused: PodU32, + + /// Validator account seed suffix + pub validator_seed_suffix: PodU32, // really `Option` so 0 is `None` + + /// Status of the validator stake account + pub status: PodStakeStatus, + + /// Validator vote account address + pub vote_account_address: Pubkey, +} + +impl ValidatorStakeInfo { + /// Get the total lamports on this validator (active and transient) + pub fn stake_lamports(&self) -> Result { + u64::from(self.active_stake_lamports) + .checked_add(self.transient_stake_lamports.into()) + .ok_or(StakePoolError::CalculationFailure) + } + + /// Performs a very cheap comparison, for checking if this validator stake + /// info matches the vote account address + pub fn memcmp_pubkey(data: &[u8], vote_address: &Pubkey) -> bool { + sol_memcmp( + &data[41..41_usize.saturating_add(PUBKEY_BYTES)], + vote_address.as_ref(), + PUBKEY_BYTES, + ) == 0 + } + + /// Performs a comparison, used to check if this validator stake + /// info has more active lamports than some limit + pub fn active_lamports_greater_than(data: &[u8], lamports: &u64) -> bool { + // without this unwrap, compute usage goes up significantly + u64::try_from_slice(&data[0..8]).unwrap() > *lamports + } + + /// Performs a comparison, used to check if this validator stake + /// info has more transient lamports than some limit + pub fn transient_lamports_greater_than(data: &[u8], lamports: &u64) -> bool { + // without this unwrap, compute usage goes up significantly + u64::try_from_slice(&data[8..16]).unwrap() > *lamports + } + + /// Check that the validator stake info is valid + pub fn is_not_removed(data: &[u8]) -> bool { + FromPrimitive::from_u8(data[40]) != Some(StakeStatus::ReadyForRemoval) + } +} + +impl Sealed for ValidatorStakeInfo {} + +impl Pack for ValidatorStakeInfo { + const LEN: usize = 73; + fn pack_into_slice(&self, data: &mut [u8]) { + // Removing this unwrap would require changing from `Pack` to some other + // trait or `bytemuck`, so it stays in for now + borsh::to_writer(data, self).unwrap(); + } + fn unpack_from_slice(src: &[u8]) -> Result { + let unpacked = Self::try_from_slice(src)?; + Ok(unpacked) + } +} + +impl ValidatorList { + /// Create an empty instance containing space for `max_validators` and + /// preferred validator keys + pub fn new(max_validators: u32) -> Self { + Self { + header: ValidatorListHeader { + account_type: AccountType::ValidatorList, + max_validators, + }, + validators: vec![ValidatorStakeInfo::default(); max_validators as usize], + } + } + + /// Calculate the number of validator entries that fit in the provided + /// length + pub fn calculate_max_validators(buffer_length: usize) -> usize { + let header_size = ValidatorListHeader::LEN.saturating_add(4); + buffer_length + .saturating_sub(header_size) + .saturating_div(ValidatorStakeInfo::LEN) + } + + /// Check if contains validator with particular pubkey + pub fn contains(&self, vote_account_address: &Pubkey) -> bool { + self.validators + .iter() + .any(|x| x.vote_account_address == *vote_account_address) + } + + /// Check if contains validator with particular pubkey + pub fn find_mut(&mut self, vote_account_address: &Pubkey) -> Option<&mut ValidatorStakeInfo> { + self.validators + .iter_mut() + .find(|x| x.vote_account_address == *vote_account_address) + } + /// Check if contains validator with particular pubkey + pub fn find(&self, vote_account_address: &Pubkey) -> Option<&ValidatorStakeInfo> { + self.validators + .iter() + .find(|x| x.vote_account_address == *vote_account_address) + } + + /// Check if the list has any active stake + pub fn has_active_stake(&self) -> bool { + self.validators + .iter() + .any(|x| u64::from(x.active_stake_lamports) > 0) + } +} + +impl ValidatorListHeader { + const LEN: usize = 1 + 4; + + /// Check if validator stake list is actually initialized as a validator + /// stake list + pub fn is_valid(&self) -> bool { + self.account_type == AccountType::ValidatorList + } + + /// Check if the validator stake list is uninitialized + pub fn is_uninitialized(&self) -> bool { + self.account_type == AccountType::Uninitialized + } + + /// Extracts a slice of ValidatorStakeInfo types from the vec part + /// of the ValidatorList + pub fn deserialize_mut_slice<'a>( + big_vec: &'a mut BigVec, + skip: usize, + len: usize, + ) -> Result<&'a mut [ValidatorStakeInfo], ProgramError> { + big_vec.deserialize_mut_slice::(skip, len) + } + + /// Extracts the validator list into its header and internal BigVec + pub fn deserialize_vec(data: &mut [u8]) -> Result<(Self, BigVec), ProgramError> { + let mut data_mut = data.borrow(); + let header = ValidatorListHeader::deserialize(&mut data_mut)?; + let length = get_instance_packed_len(&header)?; + + let big_vec = BigVec { + data: &mut data[length..], + }; + Ok((header, big_vec)) + } +} + +/// Wrapper type that "counts down" epochs, which is Borsh-compatible with the +/// native `Option` +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)] +pub enum FutureEpoch { + /// Nothing is set + None, + /// Value is ready after the next epoch boundary + One(T), + /// Value is ready after two epoch boundaries + Two(T), +} +impl Default for FutureEpoch { + fn default() -> Self { + Self::None + } +} +impl FutureEpoch { + /// Create a new value to be unlocked in a two epochs + pub fn new(value: T) -> Self { + Self::Two(value) + } +} +impl FutureEpoch { + /// Update the epoch, to be done after `get`ting the underlying value + pub fn update_epoch(&mut self) { + match self { + Self::None => {} + Self::One(_) => { + // The value has waited its last epoch + *self = Self::None; + } + // The value still has to wait one more epoch after this + Self::Two(v) => { + *self = Self::One(v.clone()); + } + } + } + + /// Get the value if it's ready, which is only at `One` epoch remaining + pub fn get(&self) -> Option<&T> { + match self { + Self::None | Self::Two(_) => None, + Self::One(v) => Some(v), + } + } +} +impl From> for Option { + fn from(v: FutureEpoch) -> Option { + match v { + FutureEpoch::None => None, + FutureEpoch::One(inner) | FutureEpoch::Two(inner) => Some(inner), + } + } +} + +/// Fee rate as a ratio, minted on `UpdateStakePoolBalance` as a proportion of +/// the rewards +/// If either the numerator or the denominator is 0, the fee is considered to be +/// 0 +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, BorshSerialize, BorshDeserialize, BorshSchema)] +pub struct Fee { + /// denominator of the fee ratio + pub denominator: u64, + /// numerator of the fee ratio + pub numerator: u64, +} + +impl Fee { + /// Applies the Fee's rates to a given amount, `amt` + /// returning the amount to be subtracted from it as fees + /// (0 if denominator is 0 or amt is 0), + /// or None if overflow occurs + #[inline] + pub fn apply(&self, amt: u64) -> Option { + if self.denominator == 0 { + return Some(0); + } + let numerator = (amt as u128).checked_mul(self.numerator as u128)?; + // ceiling the calculation by adding (denominator - 1) to the numerator + let denominator = self.denominator as u128; + numerator + .checked_add(denominator)? + .checked_sub(1)? + .checked_div(denominator) + } + + /// Withdrawal fees have some additional restrictions, + /// this fn checks if those are met, returning an error if not. + /// Does nothing and returns Ok if fee type is not withdrawal + pub fn check_withdrawal(&self, old_withdrawal_fee: &Fee) -> Result<(), StakePoolError> { + // If the previous withdrawal fee was 0, we allow the fee to be set to a + // maximum of (WITHDRAWAL_BASELINE_FEE * MAX_WITHDRAWAL_FEE_INCREASE) + let (old_num, old_denom) = + if old_withdrawal_fee.denominator == 0 || old_withdrawal_fee.numerator == 0 { + ( + WITHDRAWAL_BASELINE_FEE.numerator, + WITHDRAWAL_BASELINE_FEE.denominator, + ) + } else { + (old_withdrawal_fee.numerator, old_withdrawal_fee.denominator) + }; + + // Check that new_fee / old_fee <= MAX_WITHDRAWAL_FEE_INCREASE + // Program fails if provided numerator or denominator is too large, resulting in + // overflow + if (old_num as u128) + .checked_mul(self.denominator as u128) + .map(|x| x.checked_mul(MAX_WITHDRAWAL_FEE_INCREASE.numerator as u128)) + .ok_or(StakePoolError::CalculationFailure)? + < (self.numerator as u128) + .checked_mul(old_denom as u128) + .map(|x| x.checked_mul(MAX_WITHDRAWAL_FEE_INCREASE.denominator as u128)) + .ok_or(StakePoolError::CalculationFailure)? + { + msg!( + "Fee increase exceeds maximum allowed, proposed increase factor ({} / {})", + self.numerator.saturating_mul(old_denom), + old_num.saturating_mul(self.denominator), + ); + return Err(StakePoolError::FeeIncreaseTooHigh); + } + Ok(()) + } +} + +impl fmt::Display for Fee { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.numerator > 0 && self.denominator > 0 { + write!(f, "{}/{}", self.numerator, self.denominator) + } else { + write!(f, "none") + } + } +} + +/// The type of fees that can be set on the stake pool +#[derive(Clone, Debug, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub enum FeeType { + /// Referral fees for SOL deposits + SolReferral(u8), + /// Referral fees for stake deposits + StakeReferral(u8), + /// Management fee paid per epoch + Epoch(Fee), + /// Stake withdrawal fee + StakeWithdrawal(Fee), + /// Deposit fee for SOL deposits + SolDeposit(Fee), + /// Deposit fee for stake deposits + StakeDeposit(Fee), + /// SOL withdrawal fee + SolWithdrawal(Fee), +} + +impl FeeType { + /// Checks if the provided fee is too high, returning an error if so + pub fn check_too_high(&self) -> Result<(), StakePoolError> { + let too_high = match self { + Self::SolReferral(pct) => *pct > 100u8, + Self::StakeReferral(pct) => *pct > 100u8, + Self::Epoch(fee) => fee.numerator > fee.denominator, + Self::StakeWithdrawal(fee) => fee.numerator > fee.denominator, + Self::SolWithdrawal(fee) => fee.numerator > fee.denominator, + Self::SolDeposit(fee) => fee.numerator > fee.denominator, + Self::StakeDeposit(fee) => fee.numerator > fee.denominator, + }; + if too_high { + msg!("Fee greater than 100%: {:?}", self); + return Err(StakePoolError::FeeTooHigh); + } + Ok(()) + } + + /// Returns if the contained fee can only be updated earliest on the next + /// epoch + #[inline] + pub fn can_only_change_next_epoch(&self) -> bool { + matches!( + self, + Self::StakeWithdrawal(_) | Self::SolWithdrawal(_) | Self::Epoch(_) + ) + } +} + +#[cfg(test)] +mod test { + #![allow(clippy::arithmetic_side_effects)] + use { + super::*, + proptest::prelude::*, + solana_program::{ + borsh1::{get_packed_len, try_from_slice_unchecked}, + clock::{DEFAULT_SLOTS_PER_EPOCH, DEFAULT_S_PER_SLOT, SECONDS_PER_DAY}, + native_token::LAMPORTS_PER_SOL, + }, + }; + + fn uninitialized_validator_list() -> ValidatorList { + ValidatorList { + header: ValidatorListHeader { + account_type: AccountType::Uninitialized, + max_validators: 0, + }, + validators: vec![], + } + } + + fn test_validator_list(max_validators: u32) -> ValidatorList { + ValidatorList { + header: ValidatorListHeader { + account_type: AccountType::ValidatorList, + max_validators, + }, + validators: vec![ + ValidatorStakeInfo { + status: StakeStatus::Active.into(), + vote_account_address: Pubkey::new_from_array([1; 32]), + active_stake_lamports: u64::from_le_bytes([255; 8]).into(), + transient_stake_lamports: u64::from_le_bytes([128; 8]).into(), + last_update_epoch: u64::from_le_bytes([64; 8]).into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: 0.into(), + }, + ValidatorStakeInfo { + status: StakeStatus::DeactivatingTransient.into(), + vote_account_address: Pubkey::new_from_array([2; 32]), + active_stake_lamports: 998877665544.into(), + transient_stake_lamports: 222222222.into(), + last_update_epoch: 11223445566.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: 0.into(), + }, + ValidatorStakeInfo { + status: StakeStatus::ReadyForRemoval.into(), + vote_account_address: Pubkey::new_from_array([3; 32]), + active_stake_lamports: 0.into(), + transient_stake_lamports: 0.into(), + last_update_epoch: 999999999999999.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: 0.into(), + }, + ], + } + } + + #[test] + fn state_packing() { + let max_validators = 10_000; + let size = get_instance_packed_len(&ValidatorList::new(max_validators)).unwrap(); + let stake_list = uninitialized_validator_list(); + let mut byte_vec = vec![0u8; size]; + let bytes = byte_vec.as_mut_slice(); + borsh::to_writer(bytes, &stake_list).unwrap(); + let stake_list_unpacked = try_from_slice_unchecked::(&byte_vec).unwrap(); + assert_eq!(stake_list_unpacked, stake_list); + + // Empty, one preferred key + let stake_list = ValidatorList { + header: ValidatorListHeader { + account_type: AccountType::ValidatorList, + max_validators: 0, + }, + validators: vec![], + }; + let mut byte_vec = vec![0u8; size]; + let bytes = byte_vec.as_mut_slice(); + borsh::to_writer(bytes, &stake_list).unwrap(); + let stake_list_unpacked = try_from_slice_unchecked::(&byte_vec).unwrap(); + assert_eq!(stake_list_unpacked, stake_list); + + // With several accounts + let stake_list = test_validator_list(max_validators); + let mut byte_vec = vec![0u8; size]; + let bytes = byte_vec.as_mut_slice(); + borsh::to_writer(bytes, &stake_list).unwrap(); + let stake_list_unpacked = try_from_slice_unchecked::(&byte_vec).unwrap(); + assert_eq!(stake_list_unpacked, stake_list); + } + + #[test] + fn validator_list_active_stake() { + let max_validators = 10_000; + let mut validator_list = test_validator_list(max_validators); + assert!(validator_list.has_active_stake()); + for validator in validator_list.validators.iter_mut() { + validator.active_stake_lamports = 0.into(); + } + assert!(!validator_list.has_active_stake()); + } + + #[test] + fn validator_list_deserialize_mut_slice() { + let max_validators = 10; + let stake_list = test_validator_list(max_validators); + let mut serialized = borsh::to_vec(&stake_list).unwrap(); + let (header, mut big_vec) = ValidatorListHeader::deserialize_vec(&mut serialized).unwrap(); + let list = ValidatorListHeader::deserialize_mut_slice( + &mut big_vec, + 0, + stake_list.validators.len(), + ) + .unwrap(); + assert_eq!(header.account_type, AccountType::ValidatorList); + assert_eq!(header.max_validators, max_validators); + assert!(list + .iter() + .zip(stake_list.validators.iter()) + .all(|(a, b)| a == b)); + + let list = ValidatorListHeader::deserialize_mut_slice(&mut big_vec, 1, 2).unwrap(); + assert!(list + .iter() + .zip(stake_list.validators[1..].iter()) + .all(|(a, b)| a == b)); + let list = ValidatorListHeader::deserialize_mut_slice(&mut big_vec, 2, 1).unwrap(); + assert!(list + .iter() + .zip(stake_list.validators[2..].iter()) + .all(|(a, b)| a == b)); + let list = ValidatorListHeader::deserialize_mut_slice(&mut big_vec, 0, 2).unwrap(); + assert!(list + .iter() + .zip(stake_list.validators[..2].iter()) + .all(|(a, b)| a == b)); + + assert_eq!( + ValidatorListHeader::deserialize_mut_slice(&mut big_vec, 0, 4).unwrap_err(), + ProgramError::AccountDataTooSmall + ); + assert_eq!( + ValidatorListHeader::deserialize_mut_slice(&mut big_vec, 1, 3).unwrap_err(), + ProgramError::AccountDataTooSmall + ); + } + + #[test] + fn validator_list_iter() { + let max_validators = 10; + let stake_list = test_validator_list(max_validators); + let mut serialized = borsh::to_vec(&stake_list).unwrap(); + let (_, big_vec) = ValidatorListHeader::deserialize_vec(&mut serialized).unwrap(); + for (a, b) in big_vec + .deserialize_slice::(0, big_vec.len() as usize) + .unwrap() + .iter() + .zip(stake_list.validators.iter()) + { + assert_eq!(a, b); + } + } + + proptest! { + #[test] + fn stake_list_size_calculation(test_amount in 0..=100_000_u32) { + let validators = ValidatorList::new(test_amount); + let size = get_instance_packed_len(&validators).unwrap(); + assert_eq!(ValidatorList::calculate_max_validators(size), test_amount as usize); + assert_eq!(ValidatorList::calculate_max_validators(size.saturating_add(1)), test_amount as usize); + assert_eq!(ValidatorList::calculate_max_validators(size.saturating_add(get_packed_len::())), (test_amount + 1)as usize); + assert_eq!(ValidatorList::calculate_max_validators(size.saturating_sub(1)), (test_amount.saturating_sub(1)) as usize); + } + } + + prop_compose! { + fn fee()(denominator in 1..=u16::MAX)( + denominator in Just(denominator), + numerator in 0..=denominator, + ) -> (u64, u64) { + (numerator as u64, denominator as u64) + } + } + + prop_compose! { + fn total_stake_and_rewards()(total_lamports in 1..u64::MAX)( + total_lamports in Just(total_lamports), + rewards in 0..=total_lamports, + ) -> (u64, u64) { + (total_lamports - rewards, rewards) + } + } + + #[test] + fn specific_fee_calculation() { + // 10% of 10 SOL in rewards should be 1 SOL in fees + let epoch_fee = Fee { + numerator: 1, + denominator: 10, + }; + let mut stake_pool = StakePool { + total_lamports: 100 * LAMPORTS_PER_SOL, + pool_token_supply: 100 * LAMPORTS_PER_SOL, + epoch_fee, + ..StakePool::default() + }; + let reward_lamports = 10 * LAMPORTS_PER_SOL; + let pool_token_fee = stake_pool.calc_epoch_fee_amount(reward_lamports).unwrap(); + + stake_pool.total_lamports += reward_lamports; + stake_pool.pool_token_supply += pool_token_fee; + + let fee_lamports = stake_pool + .calc_lamports_withdraw_amount(pool_token_fee) + .unwrap(); + assert_eq!(fee_lamports, LAMPORTS_PER_SOL - 1); // off-by-one due to + // truncation + } + + #[test] + fn zero_withdraw_calculation() { + let epoch_fee = Fee { + numerator: 0, + denominator: 1, + }; + let stake_pool = StakePool { + epoch_fee, + ..StakePool::default() + }; + let fee_lamports = stake_pool.calc_lamports_withdraw_amount(0).unwrap(); + assert_eq!(fee_lamports, 0); + } + + #[test] + fn divide_by_zero_fee() { + let stake_pool = StakePool { + total_lamports: 0, + epoch_fee: Fee { + numerator: 1, + denominator: 10, + }, + ..StakePool::default() + }; + let rewards = 10; + let fee = stake_pool.calc_epoch_fee_amount(rewards).unwrap(); + assert_eq!(fee, rewards); + } + + #[test] + fn approximate_apr_calculation() { + // 8% / year means roughly .044% / epoch + let stake_pool = StakePool { + last_epoch_total_lamports: 100_000, + last_epoch_pool_token_supply: 100_000, + total_lamports: 100_044, + pool_token_supply: 100_000, + ..StakePool::default() + }; + let pool_token_value = + stake_pool.total_lamports as f64 / stake_pool.pool_token_supply as f64; + let last_epoch_pool_token_value = stake_pool.last_epoch_total_lamports as f64 + / stake_pool.last_epoch_pool_token_supply as f64; + let epoch_rate = pool_token_value / last_epoch_pool_token_value - 1.0; + const SECONDS_PER_EPOCH: f64 = DEFAULT_SLOTS_PER_EPOCH as f64 * DEFAULT_S_PER_SLOT; + const EPOCHS_PER_YEAR: f64 = SECONDS_PER_DAY as f64 * 365.25 / SECONDS_PER_EPOCH; + const EPSILON: f64 = 0.00001; + let yearly_rate = epoch_rate * EPOCHS_PER_YEAR; + assert!((yearly_rate - 0.080355).abs() < EPSILON); + } + + proptest! { + #[test] + fn fee_calculation( + (numerator, denominator) in fee(), + (total_lamports, reward_lamports) in total_stake_and_rewards(), + ) { + let epoch_fee = Fee { denominator, numerator }; + let mut stake_pool = StakePool { + total_lamports, + pool_token_supply: total_lamports, + epoch_fee, + ..StakePool::default() + }; + let pool_token_fee = stake_pool.calc_epoch_fee_amount(reward_lamports).unwrap(); + + stake_pool.total_lamports += reward_lamports; + stake_pool.pool_token_supply += pool_token_fee; + + let fee_lamports = stake_pool.calc_lamports_withdraw_amount(pool_token_fee).unwrap(); + let max_fee_lamports = u64::try_from((reward_lamports as u128) * (epoch_fee.numerator as u128) / (epoch_fee.denominator as u128)).unwrap(); + assert!(max_fee_lamports >= fee_lamports, + "Max possible fee must always be greater than or equal to what is actually withdrawn, max {} actual {}", + max_fee_lamports, + fee_lamports); + + // since we do two "flooring" conversions, the max epsilon should be + // correct up to 2 lamports (one for each floor division), plus a + // correction for huge discrepancies between rewards and total stake + let epsilon = 2 + reward_lamports / total_lamports; + assert!(max_fee_lamports - fee_lamports <= epsilon, + "Max expected fee in lamports {}, actually receive {}, epsilon {}", + max_fee_lamports, fee_lamports, epsilon); + } + } + + prop_compose! { + fn total_tokens_and_deposit()(total_lamports in 1..u64::MAX)( + total_lamports in Just(total_lamports), + pool_token_supply in 1..=total_lamports, + deposit_lamports in 1..total_lamports, + ) -> (u64, u64, u64) { + (total_lamports - deposit_lamports, pool_token_supply.saturating_sub(deposit_lamports).max(1), deposit_lamports) + } + } + + proptest! { + #[test] + fn deposit_and_withdraw( + (total_lamports, pool_token_supply, deposit_stake) in total_tokens_and_deposit() + ) { + let mut stake_pool = StakePool { + total_lamports, + pool_token_supply, + ..StakePool::default() + }; + let deposit_result = stake_pool.calc_pool_tokens_for_deposit(deposit_stake).unwrap(); + prop_assume!(deposit_result > 0); + stake_pool.total_lamports += deposit_stake; + stake_pool.pool_token_supply += deposit_result; + let withdraw_result = stake_pool.calc_lamports_withdraw_amount(deposit_result).unwrap(); + assert!(withdraw_result <= deposit_stake); + + // also test splitting the withdrawal in two operations + if deposit_result >= 2 { + let first_half_deposit = deposit_result / 2; + let first_withdraw_result = stake_pool.calc_lamports_withdraw_amount(first_half_deposit).unwrap(); + stake_pool.total_lamports -= first_withdraw_result; + stake_pool.pool_token_supply -= first_half_deposit; + let second_half_deposit = deposit_result - first_half_deposit; // do the whole thing + let second_withdraw_result = stake_pool.calc_lamports_withdraw_amount(second_half_deposit).unwrap(); + assert!(first_withdraw_result + second_withdraw_result <= deposit_stake); + } + } + } + + #[test] + fn specific_split_withdrawal() { + let total_lamports = 1_100_000_000_000; + let pool_token_supply = 1_000_000_000_000; + let deposit_stake = 3; + let mut stake_pool = StakePool { + total_lamports, + pool_token_supply, + ..StakePool::default() + }; + let deposit_result = stake_pool + .calc_pool_tokens_for_deposit(deposit_stake) + .unwrap(); + assert!(deposit_result > 0); + stake_pool.total_lamports += deposit_stake; + stake_pool.pool_token_supply += deposit_result; + let withdraw_result = stake_pool + .calc_lamports_withdraw_amount(deposit_result / 2) + .unwrap(); + assert!(withdraw_result * 2 <= deposit_stake); + } + + #[test] + fn withdraw_all() { + let total_lamports = 1_100_000_000_000; + let pool_token_supply = 1_000_000_000_000; + let mut stake_pool = StakePool { + total_lamports, + pool_token_supply, + ..StakePool::default() + }; + // take everything out at once + let withdraw_result = stake_pool + .calc_lamports_withdraw_amount(pool_token_supply) + .unwrap(); + assert_eq!(stake_pool.total_lamports, withdraw_result); + + // take out 1, then the rest + let withdraw_result = stake_pool.calc_lamports_withdraw_amount(1).unwrap(); + stake_pool.total_lamports -= withdraw_result; + stake_pool.pool_token_supply -= 1; + let withdraw_result = stake_pool + .calc_lamports_withdraw_amount(stake_pool.pool_token_supply) + .unwrap(); + assert_eq!(stake_pool.total_lamports, withdraw_result); + + // take out all except 1, then the rest + let mut stake_pool = StakePool { + total_lamports, + pool_token_supply, + ..StakePool::default() + }; + let withdraw_result = stake_pool + .calc_lamports_withdraw_amount(pool_token_supply - 1) + .unwrap(); + stake_pool.total_lamports -= withdraw_result; + stake_pool.pool_token_supply = 1; + assert_ne!(stake_pool.total_lamports, 0); + + let withdraw_result = stake_pool.calc_lamports_withdraw_amount(1).unwrap(); + assert_eq!(stake_pool.total_lamports, withdraw_result); + } +} diff --git a/program/tests/create_pool_token_metadata.rs b/program/tests/create_pool_token_metadata.rs new file mode 100644 index 00000000..b56fd655 --- /dev/null +++ b/program/tests/create_pool_token_metadata.rs @@ -0,0 +1,273 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] +mod helpers; + +use { + helpers::*, + solana_program::{instruction::InstructionError, pubkey::Pubkey}, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError::{AlreadyInUse, SignatureMissing, WrongManager}, + instruction, MINIMUM_RESERVE_LAMPORTS, + }, + test_case::test_case, +}; + +async fn setup(token_program_id: Pubkey) -> (ProgramTestContext, StakePoolAccounts) { + let mut context = program_test_with_metadata_program() + .start_with_context() + .await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + (context, stake_pool_accounts) +} + +#[test_case(spl_token::id(); "token")] +//#[test_case(spl_token_2022::id(); "token-2022")] enable once metaplex supports token-2022 +#[tokio::test] +async fn success(token_program_id: Pubkey) { + let (mut context, stake_pool_accounts) = setup(token_program_id).await; + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let metadata = get_metadata_account( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + + assert!(metadata.name.starts_with(name)); + assert!(metadata.symbol.starts_with(symbol)); + assert!(metadata.uri.starts_with(uri)); +} + +#[tokio::test] +async fn fail_manager_did_not_sign() { + let (context, stake_pool_accounts) = setup(spl_token::id()).await; + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let mut ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + ix.accounts[1].is_signer = false; + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while manager signature missing"), + } +} + +#[tokio::test] +async fn fail_wrong_manager_signed() { + let (context, stake_pool_accounts) = setup(spl_token::id()).await; + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let random_keypair = Keypair::new(); + let ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &random_keypair.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &random_keypair], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_wrong_mpl_metadata_program() { + let (context, stake_pool_accounts) = setup(spl_token::id()).await; + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let random_keypair = Keypair::new(); + let mut ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &random_keypair.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + ix.accounts[7].pubkey = Pubkey::new_unique(); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &random_keypair], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, error) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!( + "Wrong error occurs while try to create metadata with wrong mpl token metadata program ID" + ), + } +} + +#[tokio::test] +async fn fail_create_metadata_twice() { + let (context, stake_pool_accounts) = setup(spl_token::id()).await; + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix.clone()], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + + let latest_blockhash = context.banks_client.get_latest_blockhash().await.unwrap(); + let transaction_2 = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + latest_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let error = context + .banks_client + .process_transaction(transaction_2) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = AlreadyInUse as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while trying to create pool token metadata twice"), + } +} diff --git a/program/tests/decrease.rs b/program/tests/decrease.rs new file mode 100644 index 00000000..495f5d21 --- /dev/null +++ b/program/tests/decrease.rs @@ -0,0 +1,661 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + assert_matches::assert_matches, + bincode::deserialize, + helpers::*, + solana_program::{clock::Epoch, instruction::InstructionError, pubkey::Pubkey, stake}, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError, find_ephemeral_stake_program_address, + find_transient_stake_program_address, id, instruction, MINIMUM_RESERVE_LAMPORTS, + }, + test_case::test_case, +}; + +async fn setup() -> ( + ProgramTestContext, + StakePoolAccounts, + ValidatorStakeAccount, + DepositStakeAccount, + u64, + u64, +) { + let mut context = program_test().start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let stake_pool_accounts = StakePoolAccounts::default(); + let reserve_lamports = MINIMUM_RESERVE_LAMPORTS + stake_rent + current_minimum_delegation; + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + reserve_lamports, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let decrease_lamports = (current_minimum_delegation + stake_rent) * 3; + let deposit_info = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake_account, + decrease_lamports, + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + decrease_lamports, + reserve_lamports + stake_rent, + ) +} + +#[test_case(DecreaseInstruction::Additional; "additional")] +#[test_case(DecreaseInstruction::Reserve; "reserve")] +#[test_case(DecreaseInstruction::Deprecated; "deprecated")] +#[tokio::test] +async fn success(instruction_type: DecreaseInstruction) { + let ( + mut context, + stake_pool_accounts, + validator_stake, + _deposit_info, + decrease_lamports, + reserve_lamports, + ) = setup().await; + + // Save validator stake + let pre_validator_stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + + // Check no transient stake + let transient_account = context + .banks_client + .get_account(validator_stake.transient_stake_account) + .await + .unwrap(); + assert!(transient_account.is_none()); + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + decrease_lamports, + validator_stake.transient_stake_seed, + instruction_type, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check validator stake account balance + let validator_stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + let validator_stake_state = + deserialize::(&validator_stake_account.data).unwrap(); + assert_eq!( + pre_validator_stake_account.lamports - decrease_lamports, + validator_stake_account.lamports + ); + assert_eq!( + validator_stake_state + .delegation() + .unwrap() + .deactivation_epoch, + Epoch::MAX + ); + + // Check transient stake account state and balance + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let transient_stake_account = get_account( + &mut context.banks_client, + &validator_stake.transient_stake_account, + ) + .await; + let transient_stake_state = + deserialize::(&transient_stake_account.data).unwrap(); + let transient_lamports = decrease_lamports + stake_rent; + assert_eq!(transient_stake_account.lamports, transient_lamports); + let reserve_lamports = if instruction_type == DecreaseInstruction::Deprecated { + reserve_lamports + } else { + reserve_lamports - stake_rent + }; + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!(reserve_stake_account.lamports, reserve_lamports); + assert_ne!( + transient_stake_state + .delegation() + .unwrap() + .deactivation_epoch, + Epoch::MAX + ); +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let (context, stake_pool_accounts, validator_stake, _deposit_info, decrease_lamports, _) = + setup().await; + + let wrong_authority = Pubkey::new_unique(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::decrease_validator_stake_with_reserve( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &wrong_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + decrease_lamports, + validator_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidProgramAddress as u32) + ) + ); +} + +#[tokio::test] +async fn fail_with_wrong_validator_list() { + let (context, mut stake_pool_accounts, validator_stake, _deposit_info, decrease_lamports, _) = + setup().await; + + stake_pool_accounts.validator_list = Keypair::new(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::decrease_validator_stake_with_reserve( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + decrease_lamports, + validator_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidValidatorStakeList as u32) + ) + ); +} + +#[tokio::test] +async fn fail_with_unknown_validator() { + let (mut context, stake_pool_accounts, _validator_stake, _deposit_info, decrease_lamports, _) = + setup().await; + + let unknown_stake = create_unknown_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::decrease_validator_stake_with_reserve( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &unknown_stake.stake_account, + &unknown_stake.transient_stake_account, + decrease_lamports, + unknown_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::ValidatorNotFound as u32) + ) + ); +} + +#[test_case(DecreaseInstruction::Additional; "additional")] +#[test_case(DecreaseInstruction::Reserve; "reserve")] +#[test_case(DecreaseInstruction::Deprecated; "deprecated")] +#[tokio::test] +async fn fail_twice_diff_seed(instruction_type: DecreaseInstruction) { + let (mut context, stake_pool_accounts, validator_stake, _deposit_info, decrease_lamports, _) = + setup().await; + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + decrease_lamports / 3, + validator_stake.transient_stake_seed, + instruction_type, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let transient_stake_seed = validator_stake.transient_stake_seed * 100; + let transient_stake_address = find_transient_stake_program_address( + &id(), + &validator_stake.vote.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ) + .0; + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &transient_stake_address, + decrease_lamports / 2, + transient_stake_seed, + instruction_type, + ) + .await + .unwrap() + .unwrap(); + if instruction_type == DecreaseInstruction::Additional { + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::InvalidSeeds) + ); + } else { + assert_matches!( + error, + TransactionError::InstructionError( + _, + InstructionError::Custom(code) + ) if code == StakePoolError::TransientAccountInUse as u32 + ); + } +} + +#[test_case(true, DecreaseInstruction::Additional, DecreaseInstruction::Additional; "success-all-additional")] +#[test_case(true, DecreaseInstruction::Reserve, DecreaseInstruction::Additional; "success-with-additional")] +#[test_case(false, DecreaseInstruction::Additional, DecreaseInstruction::Reserve; "fail-without-additional")] +#[test_case(false, DecreaseInstruction::Reserve, DecreaseInstruction::Reserve; "fail-no-additional")] +#[tokio::test] +async fn twice( + success: bool, + first_instruction: DecreaseInstruction, + second_instruction: DecreaseInstruction, +) { + let ( + mut context, + stake_pool_accounts, + validator_stake, + _deposit_info, + decrease_lamports, + reserve_lamports, + ) = setup().await; + + let pre_stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let first_decrease = decrease_lamports / 3; + let second_decrease = decrease_lamports / 2; + let total_decrease = first_decrease + second_decrease; + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + first_decrease, + validator_stake.transient_stake_seed, + first_instruction, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + second_decrease, + validator_stake.transient_stake_seed, + second_instruction, + ) + .await; + + if success { + assert!(error.is_none(), "{:?}", error); + // no ephemeral account + let ephemeral_stake = find_ephemeral_stake_program_address( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .0; + let ephemeral_account = context + .banks_client + .get_account(ephemeral_stake) + .await + .unwrap(); + assert!(ephemeral_account.is_none()); + + // Check validator stake account balance + let stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + let stake_state = deserialize::(&stake_account.data).unwrap(); + assert_eq!( + pre_stake_account.lamports - total_decrease, + stake_account.lamports + ); + assert_eq!( + stake_state.delegation().unwrap().deactivation_epoch, + Epoch::MAX + ); + + // Check transient stake account state and balance + let transient_stake_account = get_account( + &mut context.banks_client, + &validator_stake.transient_stake_account, + ) + .await; + let transient_stake_state = + deserialize::(&transient_stake_account.data).unwrap(); + let mut transient_lamports = total_decrease + stake_rent; + if second_instruction == DecreaseInstruction::Additional { + transient_lamports += stake_rent; + } + assert_eq!(transient_stake_account.lamports, transient_lamports); + assert_ne!( + transient_stake_state + .delegation() + .unwrap() + .deactivation_epoch, + Epoch::MAX + ); + + // marked correctly in the list + let validator_list = stake_pool_accounts + .get_validator_list(&mut context.banks_client) + .await; + let entry = validator_list.find(&validator_stake.vote.pubkey()).unwrap(); + assert_eq!( + u64::from(entry.transient_stake_lamports), + transient_lamports + ); + + // reserve deducted properly + let mut reserve_lamports = reserve_lamports - stake_rent; + if second_instruction == DecreaseInstruction::Additional { + reserve_lamports -= stake_rent; + } + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!(reserve_stake_account.lamports, reserve_lamports); + } else { + let error = error.unwrap().unwrap(); + assert_matches!( + error, + TransactionError::InstructionError( + _, + InstructionError::Custom(code) + ) if code == StakePoolError::TransientAccountInUse as u32 + ); + } +} + +#[test_case(DecreaseInstruction::Additional; "additional")] +#[test_case(DecreaseInstruction::Reserve; "reserve")] +#[test_case(DecreaseInstruction::Deprecated; "deprecated")] +#[tokio::test] +async fn fail_with_small_lamport_amount(instruction_type: DecreaseInstruction) { + let (mut context, stake_pool_accounts, validator_stake, _deposit_info, _decrease_lamports, _) = + setup().await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let lamports = rent.minimum_balance(std::mem::size_of::()); + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + lamports, + validator_stake.transient_stake_seed, + instruction_type, + ) + .await + .unwrap() + .unwrap(); + + assert_matches!( + error, + TransactionError::InstructionError(_, InstructionError::AccountNotRentExempt) + ); +} + +#[test_case(DecreaseInstruction::Additional; "additional")] +#[test_case(DecreaseInstruction::Reserve; "reserve")] +#[test_case(DecreaseInstruction::Deprecated; "deprecated")] +#[tokio::test] +async fn fail_big_overdraw(instruction_type: DecreaseInstruction) { + let (mut context, stake_pool_accounts, validator_stake, deposit_info, _decrease_lamports, _) = + setup().await; + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports * 1_000_000, + validator_stake.transient_stake_seed, + instruction_type, + ) + .await + .unwrap() + .unwrap(); + + assert_matches!( + error, + TransactionError::InstructionError(_, InstructionError::InsufficientFunds) + ); +} + +#[test_case(DecreaseInstruction::Additional; "additional")] +#[test_case(DecreaseInstruction::Reserve; "reserve")] +#[test_case(DecreaseInstruction::Deprecated; "deprecated")] +#[tokio::test] +async fn fail_overdraw(instruction_type: DecreaseInstruction) { + let (mut context, stake_pool_accounts, validator_stake, deposit_info, _decrease_lamports, _) = + setup().await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports + stake_rent + 1, + validator_stake.transient_stake_seed, + instruction_type, + ) + .await + .unwrap() + .unwrap(); + + assert_matches!( + error, + TransactionError::InstructionError(_, InstructionError::InsufficientFunds) + ); +} + +#[tokio::test] +async fn fail_additional_with_increasing() { + let (mut context, stake_pool_accounts, validator_stake, _, decrease_lamports, _) = + setup().await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + // warp forward to activation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + current_minimum_delegation, + validator_stake.transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + decrease_lamports / 2, + validator_stake.transient_stake_seed, + DecreaseInstruction::Additional, + ) + .await + .unwrap() + .unwrap(); + + assert_matches!( + error, + TransactionError::InstructionError( + _, + InstructionError::Custom(code) + ) if code == StakePoolError::WrongStakeStake as u32 + ); +} diff --git a/program/tests/deposit.rs b/program/tests/deposit.rs new file mode 100644 index 00000000..752a6bec --- /dev/null +++ b/program/tests/deposit.rs @@ -0,0 +1,905 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + stake, sysvar, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{error::StakePoolError, id, instruction, state, MINIMUM_RESERVE_LAMPORTS}, + spl_token::error as token_error, + test_case::test_case, +}; + +async fn setup( + token_program_id: Pubkey, +) -> ( + ProgramTestContext, + StakePoolAccounts, + ValidatorStakeAccount, + Keypair, + Pubkey, + Pubkey, + u64, +) { + let mut context = program_test().start_with_context().await; + + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let user = Keypair::new(); + // make stake account + let deposit_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + + let stake_lamports = create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &authorized, + &lockup, + TEST_STAKE_AMOUNT, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake.pubkey(), + &user, + &validator_stake_account.vote.pubkey(), + ) + .await; + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + // make pool token account + let pool_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake.pubkey(), + pool_token_account.pubkey(), + stake_lamports, + ) +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success(token_program_id: Pubkey) { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + stake_lamports, + ) = setup(token_program_id).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // Save stake pool state before depositing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + // Save validator stake account record before depositing + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let pre_validator_stake_item = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + + // Save reserve state before depositing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Original stake account should be drained + assert!(context + .banks_client + .get_account(deposit_stake) + .await + .expect("get_account") + .is_none()); + + let tokens_issued = stake_lamports; // For now tokens are 1:1 to stake + + // Stake pool should add its balance to the pool balance + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports + stake_lamports + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply + tokens_issued + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + let tokens_issued_user = tokens_issued + - post_stake_pool + .calc_pool_tokens_sol_deposit_fee(stake_rent) + .unwrap() + - post_stake_pool + .calc_pool_tokens_stake_deposit_fee(stake_lamports - stake_rent) + .unwrap(); + assert_eq!(user_token_balance, tokens_issued_user); + + // Check balances in validator stake account list storage + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let post_validator_stake_item = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + assert_eq!( + post_validator_stake_item.stake_lamports().unwrap(), + pre_validator_stake_item.stake_lamports().unwrap() + stake_lamports - stake_rent, + ); + + // Check validator stake account actual SOL balance + let validator_stake_account = get_account( + &mut context.banks_client, + &validator_stake_account.stake_account, + ) + .await; + assert_eq!( + validator_stake_account.lamports, + post_validator_stake_item.stake_lamports().unwrap() + ); + assert_eq!( + u64::from(post_validator_stake_item.transient_stake_lamports), + 0 + ); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!(post_reserve_lamports, pre_reserve_lamports + stake_rent); +} + +#[tokio::test] +async fn success_with_extra_stake_lamports() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + stake_lamports, + ) = setup(spl_token::id()).await; + + let extra_lamports = TEST_STAKE_AMOUNT * 3 + 1; + + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + extra_lamports, + ) + .await; + + let referrer = Keypair::new(); + let referrer_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &referrer_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &referrer, + &[], + ) + .await + .unwrap(); + + let referrer_balance_pre = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + + let manager_pool_balance_pre = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // Save stake pool state before depositing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + // Save validator stake account record before depositing + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let pre_validator_stake_item = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + + // Save reserve state before depositing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let error = stake_pool_accounts + .deposit_stake_with_referral( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + &referrer_token_account.pubkey(), + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Original stake account should be drained + assert!(context + .banks_client + .get_account(deposit_stake) + .await + .expect("get_account") + .is_none()); + + let tokens_issued = stake_lamports + extra_lamports; + // For now tokens are 1:1 to stake + + // Stake pool should add its balance to the pool balance + + // The extra lamports will not get recorded in total stake lamports unless + // update_stake_pool_balance is called + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports + extra_lamports + stake_lamports + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply + tokens_issued + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + + let fee_tokens = post_stake_pool + .calc_pool_tokens_sol_deposit_fee(extra_lamports + stake_rent) + .unwrap() + + post_stake_pool + .calc_pool_tokens_stake_deposit_fee(stake_lamports - stake_rent) + .unwrap(); + let tokens_issued_user = tokens_issued - fee_tokens; + assert_eq!(user_token_balance, tokens_issued_user); + + let referrer_balance_post = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + + let referral_fee = stake_pool_accounts.calculate_referral_fee(fee_tokens); + let manager_fee = fee_tokens - referral_fee; + + assert_eq!(referrer_balance_post - referrer_balance_pre, referral_fee); + + let manager_pool_balance_post = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + assert_eq!( + manager_pool_balance_post - manager_pool_balance_pre, + manager_fee + ); + + // Check balances in validator stake account list storage + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let post_validator_stake_item = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + assert_eq!( + post_validator_stake_item.stake_lamports().unwrap(), + pre_validator_stake_item.stake_lamports().unwrap() + stake_lamports - stake_rent, + ); + + // Check validator stake account actual SOL balance + let validator_stake_account = get_account( + &mut context.banks_client, + &validator_stake_account.stake_account, + ) + .await; + assert_eq!( + validator_stake_account.lamports, + post_validator_stake_item.stake_lamports().unwrap() + ); + assert_eq!( + u64::from(post_validator_stake_item.transient_stake_lamports), + 0 + ); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports + stake_rent + extra_lamports + ); +} + +#[tokio::test] +async fn fail_with_wrong_stake_program_id() { + let ( + context, + stake_pool_accounts, + validator_stake_account, + _user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let wrong_stake_program = Pubkey::new_unique(); + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.stake_deposit_authority, false), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(deposit_stake, false), + AccountMeta::new(validator_stake_account.stake_account, false), + AccountMeta::new(stake_pool_accounts.reserve_stake.pubkey(), false), + AccountMeta::new(pool_token_account, false), + AccountMeta::new(stake_pool_accounts.pool_fee_account.pubkey(), false), + AccountMeta::new(stake_pool_accounts.pool_fee_account.pubkey(), false), + AccountMeta::new(stake_pool_accounts.pool_mint.pubkey(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(wrong_stake_program, false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::DepositStake).unwrap(), + }; + + let mut transaction = + Transaction::new_with_payer(&[instruction], Some(&context.payer.pubkey())); + transaction.sign(&[&context.payer], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong stake program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_token_program_id() { + let ( + context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let wrong_token_program = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &instruction::deposit_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.withdraw_authority, + &deposit_stake, + &user.pubkey(), + &validator_stake_account.stake_account, + &stake_pool_accounts.reserve_stake.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &wrong_token_program.pubkey(), + ), + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer, &user], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong token program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_validator_list_account() { + let ( + mut context, + mut stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let wrong_validator_list = Keypair::new(); + stake_pool_accounts.validator_list = wrong_validator_list; + + let transaction_error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + ) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn fail_with_unknown_validator() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let unknown_stake = create_unknown_validator_stake( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .await; + + let user = Keypair::new(); + let user_pool_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &user_pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + // make stake account + let user_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake, + &authorized, + &lockup, + TEST_STAKE_AMOUNT, + ) + .await; + delegate_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user, + &unknown_stake.vote.pubkey(), + ) + .await; + + let error = stake_pool_accounts + .deposit_stake( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user_pool_account.pubkey(), + &unknown_stake.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(StakePoolError::ValidatorNotFound as u32) + ) + ); +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let ( + mut context, + mut stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + stake_pool_accounts.withdraw_authority = Pubkey::new_unique(); + + let transaction_error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong withdraw authority"), + } +} + +#[tokio::test] +async fn fail_with_wrong_mint_for_receiver_acc() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + _pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let outside_mint = Keypair::new(); + let outside_withdraw_auth = Keypair::new(); + let outside_manager = Keypair::new(); + let outside_pool_fee_acc = Keypair::new(); + + create_mint( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &outside_mint, + &outside_withdraw_auth.pubkey(), + 0, + &[], + ) + .await + .unwrap(); + + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &outside_pool_fee_acc, + &outside_mint.pubkey(), + &outside_manager, + &[], + ) + .await + .unwrap(); + + let transaction_error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &outside_pool_fee_acc.pubkey(), + &validator_stake_account.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = token_error::TokenError::MintMismatch as u32; + assert_eq!(error_index, program_error); + } + _ => { + panic!("Wrong error occurs while try to deposit with wrong mint from receiver account") + } + } +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success_with_slippage(token_program_id: Pubkey) { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + stake_lamports, + ) = setup(token_program_id).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // Save stake pool state before depositing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + let tokens_issued = stake_lamports; // For now tokens are 1:1 to stake + let tokens_issued_user = tokens_issued + - pre_stake_pool + .calc_pool_tokens_sol_deposit_fee(stake_rent) + .unwrap() + - pre_stake_pool + .calc_pool_tokens_stake_deposit_fee(stake_lamports - stake_rent) + .unwrap(); + + let error = stake_pool_accounts + .deposit_stake_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + tokens_issued_user + 1, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(StakePoolError::ExceededSlippage as u32) + ) + ); + + let error = stake_pool_accounts + .deposit_stake_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake_account.stake_account, + &user, + tokens_issued_user, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Original stake account should be drained + assert!(context + .banks_client + .get_account(deposit_stake) + .await + .expect("get_account") + .is_none()); + + // Stake pool should add its balance to the pool balance + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports + stake_lamports + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply + tokens_issued + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + assert_eq!(user_token_balance, tokens_issued_user); +} diff --git a/program/tests/deposit_authority.rs b/program/tests/deposit_authority.rs new file mode 100644 index 00000000..e5e46a7e --- /dev/null +++ b/program/tests/deposit_authority.rs @@ -0,0 +1,222 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{instruction::InstructionError, stake}, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + signature::{Keypair, Signer}, + transaction::TransactionError, + }, + spl_stake_pool::{error::StakePoolError, state::StakePool, MINIMUM_RESERVE_LAMPORTS}, +}; + +#[tokio::test] +async fn success_initialize() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let deposit_authority = Keypair::new(); + let stake_pool_accounts = StakePoolAccounts::new_with_deposit_authority(deposit_authority); + let deposit_authority = stake_pool_accounts.stake_deposit_authority; + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + // Stake pool now exists + let stake_pool_account = + get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool_account.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_deposit_authority, deposit_authority); + assert_eq!(stake_pool.sol_deposit_authority.unwrap(), deposit_authority); +} + +#[tokio::test] +async fn success_deposit() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_deposit_authority = Keypair::new(); + let stake_pool_accounts = + StakePoolAccounts::new_with_deposit_authority(stake_deposit_authority); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + stake_pool_accounts.stake_deposit_authority_keypair.as_ref(), + ) + .await; + + let user = Keypair::new(); + let user_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + + let _stake_lamports = create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake, + &authorized, + &lockup, + TEST_STAKE_AMOUNT, + ) + .await; + + delegate_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user, + &validator_stake_account.vote.pubkey(), + ) + .await; + + // make pool token account + let user_pool_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &user_pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + let error = stake_pool_accounts + .deposit_stake( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user_pool_account.pubkey(), + &validator_stake_account.stake_account, + &user, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_deposit_without_authority_signature() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_deposit_authority = Keypair::new(); + let mut stake_pool_accounts = + StakePoolAccounts::new_with_deposit_authority(stake_deposit_authority); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + stake_pool_accounts.stake_deposit_authority_keypair.as_ref(), + ) + .await; + + let user = Keypair::new(); + let user_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + + let _stake_lamports = create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake, + &authorized, + &lockup, + TEST_STAKE_AMOUNT, + ) + .await; + + delegate_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user, + &validator_stake_account.vote.pubkey(), + ) + .await; + + // make pool token account + let user_pool_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &user_pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + let wrong_depositor = Keypair::new(); + stake_pool_accounts.stake_deposit_authority = wrong_depositor.pubkey(); + stake_pool_accounts.stake_deposit_authority_keypair = Some(wrong_depositor); + + let error = stake_pool_accounts + .deposit_stake( + &mut banks_client, + &payer, + &recent_blockhash, + &user_stake.pubkey(), + &user_pool_account.pubkey(), + &validator_stake_account.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + assert_eq!( + error_index, + StakePoolError::InvalidStakeDepositAuthority as u32 + ); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong stake program ID"), + } +} diff --git a/program/tests/deposit_edge_cases.rs b/program/tests/deposit_edge_cases.rs new file mode 100644 index 00000000..a11ea428 --- /dev/null +++ b/program/tests/deposit_edge_cases.rs @@ -0,0 +1,335 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, instruction::InstructionError, pubkey::Pubkey, stake, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{error::StakePoolError, id, instruction, state, MINIMUM_RESERVE_LAMPORTS}, +}; + +async fn setup( + token_program_id: Pubkey, +) -> ( + ProgramTestContext, + StakePoolAccounts, + ValidatorStakeAccount, + Keypair, + Pubkey, + Pubkey, + u64, +) { + let mut context = program_test().start_with_context().await; + + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let user = Keypair::new(); + // make stake account + let deposit_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + + let stake_lamports = create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &authorized, + &lockup, + TEST_STAKE_AMOUNT, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake.pubkey(), + &user, + &validator_stake_account.vote.pubkey(), + ) + .await; + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + // make pool token account + let pool_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake.pubkey(), + pool_token_account.pubkey(), + stake_lamports, + ) +} + +#[tokio::test] +async fn success_with_preferred_deposit() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Deposit, + Some(validator_stake.vote.pubkey()), + ) + .await; + + let error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake.stake_account, + &user, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_with_wrong_preferred_deposit() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Deposit, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + let error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &pool_token_account, + &validator_stake.stake_account, + &user, + ) + .await + .unwrap() + .unwrap(); + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + assert_eq!( + error_index, + StakePoolError::IncorrectDepositVoteAddress as u32 + ); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong stake program ID"), + } +} + +#[tokio::test] +async fn success_with_referral_fee() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + stake_lamports, + ) = setup(spl_token::id()).await; + + let referrer = Keypair::new(); + let referrer_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &referrer_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &referrer, + &[], + ) + .await + .unwrap(); + + let referrer_balance_pre = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + + let mut transaction = Transaction::new_with_payer( + &instruction::deposit_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.withdraw_authority, + &deposit_stake, + &user.pubkey(), + &validator_stake_account.stake_account, + &stake_pool_accounts.reserve_stake.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &referrer_token_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + ), + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer, &user], context.last_blockhash); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let referrer_balance_post = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let fee_tokens = stake_pool + .calc_pool_tokens_sol_deposit_fee(stake_rent) + .unwrap() + + stake_pool + .calc_pool_tokens_stake_deposit_fee(stake_lamports - stake_rent) + .unwrap(); + let referral_fee = stake_pool_accounts.calculate_referral_fee(fee_tokens); + assert!(referral_fee > 0); + assert_eq!(referrer_balance_pre + referral_fee, referrer_balance_post); +} + +#[tokio::test] +async fn fail_with_invalid_referrer() { + let ( + context, + stake_pool_accounts, + validator_stake_account, + user, + deposit_stake, + pool_token_account, + _stake_lamports, + ) = setup(spl_token::id()).await; + + let invalid_token_account = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &instruction::deposit_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.withdraw_authority, + &deposit_stake, + &user.pubkey(), + &validator_stake_account.stake_account, + &stake_pool_accounts.reserve_stake.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &invalid_token_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + ), + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer, &user], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::InvalidAccountData) => (), + _ => panic!( + "Wrong error occurs while try to make a deposit with an invalid referrer account" + ), + } +} diff --git a/program/tests/deposit_sol.rs b/program/tests/deposit_sol.rs new file mode 100644 index 00000000..aebeb4f1 --- /dev/null +++ b/program/tests/deposit_sol.rs @@ -0,0 +1,605 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, instruction::InstructionError, pubkey::Pubkey, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{ + error, id, + instruction::{self, FundingType}, + state, MINIMUM_RESERVE_LAMPORTS, + }, + spl_token::error as token_error, + test_case::test_case, +}; + +async fn setup( + token_program_id: Pubkey, +) -> (ProgramTestContext, StakePoolAccounts, Keypair, Pubkey) { + let mut context = program_test().start_with_context().await; + + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let user = Keypair::new(); + + // make pool token account for user + let pool_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + user, + pool_token_account.pubkey(), + ) +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success(token_program_id: Pubkey) { + let (mut context, stake_pool_accounts, _user, pool_token_account) = + setup(token_program_id).await; + + // Save stake pool state before depositing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + // Save reserve state before depositing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let error = stake_pool_accounts + .deposit_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &pool_token_account, + TEST_STAKE_AMOUNT, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let tokens_issued = TEST_STAKE_AMOUNT; // For now tokens are 1:1 to stake + + // Stake pool should add its balance to the pool balance + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports + TEST_STAKE_AMOUNT + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply + tokens_issued + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + let tokens_issued_user = + tokens_issued - stake_pool_accounts.calculate_sol_deposit_fee(tokens_issued); + assert_eq!(user_token_balance, tokens_issued_user); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports + TEST_STAKE_AMOUNT + ); +} + +#[tokio::test] +async fn fail_with_wrong_token_program_id() { + let (context, stake_pool_accounts, _user, pool_token_account) = setup(spl_token::id()).await; + + let wrong_token_program = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::deposit_sol( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.reserve_stake.pubkey(), + &context.payer.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &wrong_token_program.pubkey(), + TEST_STAKE_AMOUNT, + )], + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong token program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let (mut context, mut stake_pool_accounts, _user, pool_token_account) = + setup(spl_token::id()).await; + + stake_pool_accounts.withdraw_authority = Pubkey::new_unique(); + + let transaction_error = stake_pool_accounts + .deposit_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &pool_token_account, + TEST_STAKE_AMOUNT, + None, + ) + .await + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to make a deposit with wrong withdraw authority"), + } +} + +#[tokio::test] +async fn fail_with_wrong_mint_for_receiver_acc() { + let (mut context, stake_pool_accounts, _user, _pool_token_account) = + setup(spl_token::id()).await; + + let outside_mint = Keypair::new(); + let outside_withdraw_auth = Keypair::new(); + let outside_manager = Keypair::new(); + let outside_pool_fee_acc = Keypair::new(); + + create_mint( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &outside_mint, + &outside_withdraw_auth.pubkey(), + 0, + &[], + ) + .await + .unwrap(); + + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &outside_pool_fee_acc, + &outside_mint.pubkey(), + &outside_manager, + &[], + ) + .await + .unwrap(); + + let transaction_error = stake_pool_accounts + .deposit_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &outside_pool_fee_acc.pubkey(), + TEST_STAKE_AMOUNT, + None, + ) + .await + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = token_error::TokenError::MintMismatch as u32; + assert_eq!(error_index, program_error); + } + _ => { + panic!("Wrong error occurs while try to deposit with wrong mint from receiver account") + } + } +} + +#[tokio::test] +async fn success_with_sol_deposit_authority() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let user = Keypair::new(); + + // make pool token account + let user_pool_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &user_pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + let error = stake_pool_accounts + .deposit_sol( + &mut banks_client, + &payer, + &recent_blockhash, + &user_pool_account.pubkey(), + TEST_STAKE_AMOUNT, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let sol_deposit_authority = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&sol_deposit_authority.pubkey()), + FundingType::SolDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let error = stake_pool_accounts + .deposit_sol( + &mut banks_client, + &payer, + &recent_blockhash, + &user_pool_account.pubkey(), + TEST_STAKE_AMOUNT, + Some(&sol_deposit_authority), + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_without_sol_deposit_authority_signature() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let sol_deposit_authority = Keypair::new(); + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let user = Keypair::new(); + + // make pool token account + let user_pool_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &user_pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&sol_deposit_authority.pubkey()), + FundingType::SolDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let wrong_depositor = Keypair::new(); + + let error = stake_pool_accounts + .deposit_sol( + &mut banks_client, + &payer, + &recent_blockhash, + &user_pool_account.pubkey(), + TEST_STAKE_AMOUNT, + Some(&wrong_depositor), + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + assert_eq!( + error_index, + error::StakePoolError::InvalidSolDepositAuthority as u32 + ); + } + _ => panic!("Wrong error occurs while trying to make a deposit without SOL deposit authority signature"), + } +} + +#[tokio::test] +async fn success_with_referral_fee() { + let (mut context, stake_pool_accounts, _user, pool_token_account) = + setup(spl_token::id()).await; + + let referrer = Keypair::new(); + let referrer_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &referrer_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &referrer, + &[], + ) + .await + .unwrap(); + + let referrer_balance_pre = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::deposit_sol( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.reserve_stake.pubkey(), + &context.payer.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &referrer_token_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + TEST_STAKE_AMOUNT, + )], + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer], context.last_blockhash); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let referrer_balance_post = + get_token_balance(&mut context.banks_client, &referrer_token_account.pubkey()).await; + let referral_fee = stake_pool_accounts.calculate_sol_referral_fee( + stake_pool_accounts.calculate_sol_deposit_fee(TEST_STAKE_AMOUNT), + ); + assert!(referral_fee > 0); + assert_eq!(referrer_balance_pre + referral_fee, referrer_balance_post); +} + +#[tokio::test] +async fn fail_with_invalid_referrer() { + let (context, stake_pool_accounts, _user, pool_token_account) = setup(spl_token::id()).await; + + let invalid_token_account = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::deposit_sol( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.reserve_stake.pubkey(), + &context.payer.pubkey(), + &pool_token_account, + &stake_pool_accounts.pool_fee_account.pubkey(), + &invalid_token_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + TEST_STAKE_AMOUNT, + )], + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match transaction_error { + TransactionError::InstructionError(_, InstructionError::InvalidAccountData) => (), + _ => panic!( + "Wrong error occurs while try to make a deposit with an invalid referrer account" + ), + } +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success_with_slippage(token_program_id: Pubkey) { + let (mut context, stake_pool_accounts, _user, pool_token_account) = + setup(token_program_id).await; + + // Save stake pool state before depositing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + // Save reserve state before depositing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let new_pool_tokens = pre_stake_pool + .calc_pool_tokens_for_deposit(TEST_STAKE_AMOUNT) + .unwrap(); + let pool_tokens_sol_deposit_fee = pre_stake_pool + .calc_pool_tokens_sol_deposit_fee(new_pool_tokens) + .unwrap(); + let tokens_issued = new_pool_tokens - pool_tokens_sol_deposit_fee; + + // Fail with 1 more token in slippage + let error = stake_pool_accounts + .deposit_sol_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &pool_token_account, + TEST_STAKE_AMOUNT, + tokens_issued + 1, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(error::StakePoolError::ExceededSlippage as u32) + ) + ); + + // Succeed with exact return amount + let error = stake_pool_accounts + .deposit_sol_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &pool_token_account, + TEST_STAKE_AMOUNT, + tokens_issued, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Stake pool should add its balance to the pool balance + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports + TEST_STAKE_AMOUNT + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply + new_pool_tokens + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + assert_eq!(user_token_balance, tokens_issued); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports + TEST_STAKE_AMOUNT + ); +} diff --git a/program/tests/fixtures/mpl_token_metadata.so b/program/tests/fixtures/mpl_token_metadata.so new file mode 100755 index 00000000..399c584c Binary files /dev/null and b/program/tests/fixtures/mpl_token_metadata.so differ diff --git a/program/tests/force_destake.rs b/program/tests/force_destake.rs new file mode 100644 index 00000000..bab4003a --- /dev/null +++ b/program/tests/force_destake.rs @@ -0,0 +1,343 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + pubkey::Pubkey, + stake::{ + self, + stake_flags::StakeFlags, + state::{Authorized, Delegation, Lockup, Meta, Stake, StakeStateV2}, + }, + }, + solana_program_test::*, + solana_sdk::{ + account::{Account, WritableAccount}, + clock::Epoch, + signature::Signer, + transaction::TransactionError, + }, + spl_stake_pool::{ + error::StakePoolError, + find_stake_program_address, find_transient_stake_program_address, id, + state::{AccountType, StakeStatus, ValidatorList, ValidatorListHeader, ValidatorStakeInfo}, + MINIMUM_ACTIVE_STAKE, + }, + std::num::NonZeroU32, +}; + +async fn setup( + stake_pool_accounts: &StakePoolAccounts, + forced_stake: &StakeStateV2, + voter_pubkey: &Pubkey, +) -> (ProgramTestContext, Option) { + let mut program_test = program_test(); + + let stake_pool_pubkey = stake_pool_accounts.stake_pool.pubkey(); + let (mut stake_pool, mut validator_list) = stake_pool_accounts.state(); + + let _ = add_vote_account_with_pubkey(voter_pubkey, &mut program_test); + let mut data = vec![0; std::mem::size_of::()]; + bincode::serialize_into(&mut data[..], forced_stake).unwrap(); + + let stake_account = Account::create( + TEST_STAKE_AMOUNT + STAKE_ACCOUNT_RENT_EXEMPTION, + data, + stake::program::id(), + false, + Epoch::default(), + ); + + let raw_validator_seed = 42; + let validator_seed = NonZeroU32::new(raw_validator_seed); + let (stake_address, _) = + find_stake_program_address(&id(), voter_pubkey, &stake_pool_pubkey, validator_seed); + program_test.add_account(stake_address, stake_account); + let active_stake_lamports = TEST_STAKE_AMOUNT - MINIMUM_ACTIVE_STAKE; + // add to validator list + validator_list.validators.push(ValidatorStakeInfo { + status: StakeStatus::Active.into(), + vote_account_address: *voter_pubkey, + active_stake_lamports: active_stake_lamports.into(), + transient_stake_lamports: 0.into(), + last_update_epoch: 0.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: raw_validator_seed.into(), + }); + + stake_pool.total_lamports += active_stake_lamports; + stake_pool.pool_token_supply += active_stake_lamports; + + add_reserve_stake_account( + &mut program_test, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.withdraw_authority, + TEST_STAKE_AMOUNT, + ); + add_stake_pool_account( + &mut program_test, + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool, + ); + add_validator_list_account( + &mut program_test, + &stake_pool_accounts.validator_list.pubkey(), + &validator_list, + stake_pool_accounts.max_validators, + ); + + add_mint_account( + &mut program_test, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.withdraw_authority, + stake_pool.pool_token_supply, + ); + add_token_account( + &mut program_test, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager.pubkey(), + ); + + let context = program_test.start_with_context().await; + (context, validator_seed) +} + +#[tokio::test] +async fn success_update() { + let stake_pool_accounts = StakePoolAccounts::default(); + let meta = Meta { + rent_exempt_reserve: STAKE_ACCOUNT_RENT_EXEMPTION, + authorized: Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + lockup: Lockup::default(), + }; + let voter_pubkey = Pubkey::new_unique(); + let (mut context, validator_seed) = setup( + &stake_pool_accounts, + &StakeStateV2::Initialized(meta), + &voter_pubkey, + ) + .await; + let pre_reserve_lamports = context + .banks_client + .get_account(stake_pool_accounts.reserve_stake.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + let (stake_address, _) = find_stake_program_address( + &id(), + &voter_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + validator_seed, + ); + let validator_stake_lamports = context + .banks_client + .get_account(stake_address) + .await + .unwrap() + .unwrap() + .lamports; + // update should merge the destaked validator stake account into the reserve + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + let post_reserve_lamports = context + .banks_client + .get_account(stake_pool_accounts.reserve_stake.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports + validator_stake_lamports + ); + // test no more validator stake account + assert!(context + .banks_client + .get_account(stake_address) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn fail_increase() { + let stake_pool_accounts = StakePoolAccounts::default(); + let meta = Meta { + rent_exempt_reserve: STAKE_ACCOUNT_RENT_EXEMPTION, + authorized: Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + lockup: Lockup::default(), + }; + let voter_pubkey = Pubkey::new_unique(); + let (mut context, validator_seed) = setup( + &stake_pool_accounts, + &StakeStateV2::Initialized(meta), + &voter_pubkey, + ) + .await; + let (stake_address, _) = find_stake_program_address( + &id(), + &voter_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + validator_seed, + ); + let transient_stake_seed = 0; + let transient_stake_address = find_transient_stake_program_address( + &id(), + &voter_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ) + .0; + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &transient_stake_address, + &stake_address, + &voter_pubkey, + MINIMUM_ACTIVE_STAKE, + transient_stake_seed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::WrongStakeStake as u32) + ) + ); +} + +#[tokio::test] +async fn success_remove_validator() { + let stake_pool_accounts = StakePoolAccounts::default(); + let meta = Meta { + rent_exempt_reserve: STAKE_ACCOUNT_RENT_EXEMPTION, + authorized: Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + lockup: Lockup::default(), + }; + let voter_pubkey = Pubkey::new_unique(); + let stake = Stake { + delegation: Delegation { + voter_pubkey, + stake: TEST_STAKE_AMOUNT, + activation_epoch: 0, + deactivation_epoch: 0, + ..Delegation::default() + }, + credits_observed: 1, + }; + let (mut context, validator_seed) = setup( + &stake_pool_accounts, + &StakeStateV2::Stake(meta, stake, StakeFlags::empty()), + &voter_pubkey, + ) + .await; + + // move forward to after deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let (stake_address, _) = find_stake_program_address( + &id(), + &voter_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + validator_seed, + ); + let transient_stake_seed = 0; + let transient_stake_address = find_transient_stake_program_address( + &id(), + &voter_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ) + .0; + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_address, + &transient_stake_address, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Get a new blockhash for the next update to work + context.get_new_latest_blockhash().await.unwrap(); + + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check if account was removed from the list of stake accounts + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!( + validator_list, + ValidatorList { + header: ValidatorListHeader { + account_type: AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![] + } + ); + + // Check stake account no longer exists + let account = context + .banks_client + .get_account(stake_address) + .await + .unwrap(); + assert!(account.is_none()); +} diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs new file mode 100644 index 00000000..89baa021 --- /dev/null +++ b/program/tests/helpers/mod.rs @@ -0,0 +1,2678 @@ +#![allow(dead_code)] + +use { + borsh::BorshDeserialize, + solana_program::{ + borsh1::{get_instance_packed_len, get_packed_len, try_from_slice_unchecked}, + hash::Hash, + instruction::Instruction, + program_option::COption, + program_pack::Pack, + pubkey::Pubkey, + stake, system_instruction, system_program, + }, + solana_program_test::{processor, BanksClient, ProgramTest, ProgramTestContext}, + solana_sdk::{ + account::{Account as SolanaAccount, WritableAccount}, + clock::{Clock, Epoch}, + compute_budget::ComputeBudgetInstruction, + signature::{Keypair, Signer}, + transaction::Transaction, + transport::TransportError, + }, + solana_vote_program::{ + self, vote_instruction, + vote_state::{VoteInit, VoteState, VoteStateVersions}, + }, + spl_stake_pool::{ + find_deposit_authority_program_address, find_ephemeral_stake_program_address, + find_stake_program_address, find_transient_stake_program_address, + find_withdraw_authority_program_address, id, + inline_mpl_token_metadata::{self, pda::find_metadata_account}, + instruction, minimum_delegation, + processor::Processor, + state::{self, FeeType, FutureEpoch, StakePool, ValidatorList}, + MAX_VALIDATORS_TO_UPDATE, MINIMUM_RESERVE_LAMPORTS, + }, + spl_token_2022::{ + extension::{ExtensionType, StateWithExtensionsOwned}, + native_mint, + state::{Account, Mint}, + }, + std::{convert::TryInto, num::NonZeroU32}, +}; + +pub const FIRST_NORMAL_EPOCH: u64 = 15; +pub const TEST_STAKE_AMOUNT: u64 = 1_500_000_000; +pub const MAX_TEST_VALIDATORS: u32 = 10_000; +pub const DEFAULT_VALIDATOR_STAKE_SEED: Option = NonZeroU32::new(1_010); +pub const DEFAULT_TRANSIENT_STAKE_SEED: u64 = 42; +pub const STAKE_ACCOUNT_RENT_EXEMPTION: u64 = 2_282_880; +const ACCOUNT_RENT_EXEMPTION: u64 = 1_000_000_000; // go with something big to be safe + +pub fn program_test() -> ProgramTest { + let mut program_test = ProgramTest::new("spl_stake_pool", id(), processor!(Processor::process)); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(spl_token_2022::processor::Processor::process), + ); + program_test +} + +pub fn program_test_with_metadata_program() -> ProgramTest { + let mut program_test = ProgramTest::default(); + program_test.add_program("spl_stake_pool", id(), processor!(Processor::process)); + program_test.add_program("mpl_token_metadata", inline_mpl_token_metadata::id(), None); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(spl_token_2022::processor::Processor::process), + ); + program_test +} + +pub async fn get_account(banks_client: &mut BanksClient, pubkey: &Pubkey) -> SolanaAccount { + banks_client + .get_account(*pubkey) + .await + .expect("client error") + .expect("account not found") +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_mint( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + pool_mint: &Keypair, + manager: &Pubkey, + decimals: u8, + extension_types: &[ExtensionType], +) -> Result<(), TransportError> { + assert!(extension_types.is_empty() || program_id != &spl_token::id()); + let rent = banks_client.get_rent().await.unwrap(); + let space = ExtensionType::try_calculate_account_len::(extension_types).unwrap(); + let mint_rent = rent.minimum_balance(space); + let mint_pubkey = pool_mint.pubkey(); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &mint_pubkey, + mint_rent, + space as u64, + program_id, + )]; + for extension_type in extension_types { + let instruction = match extension_type { + ExtensionType::MintCloseAuthority => + spl_token_2022::instruction::initialize_mint_close_authority( + program_id, + &mint_pubkey, + Some(manager), + ), + ExtensionType::DefaultAccountState => + spl_token_2022::extension::default_account_state::instruction::initialize_default_account_state( + program_id, + &mint_pubkey, + &spl_token_2022::state::AccountState::Initialized, + ), + ExtensionType::TransferFeeConfig => spl_token_2022::extension::transfer_fee::instruction::initialize_transfer_fee_config( + program_id, + &mint_pubkey, + Some(manager), + Some(manager), + 100, + 1_000_000, + ), + ExtensionType::InterestBearingConfig => spl_token_2022::extension::interest_bearing_mint::instruction::initialize( + program_id, + &mint_pubkey, + Some(*manager), + 600, + ), + ExtensionType::NonTransferable => + spl_token_2022::instruction::initialize_non_transferable_mint(program_id, &mint_pubkey), + _ => unimplemented!(), + }; + instructions.push(instruction.unwrap()); + } + instructions.push( + spl_token_2022::instruction::initialize_mint( + program_id, + &pool_mint.pubkey(), + manager, + None, + decimals, + ) + .unwrap(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, pool_mint], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +pub async fn transfer( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + recipient: &Pubkey, + amount: u64, +) { + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &payer.pubkey(), + recipient, + amount, + )], + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub async fn transfer_spl_tokens( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + source: &Pubkey, + mint: &Pubkey, + destination: &Pubkey, + authority: &Keypair, + amount: u64, + decimals: u8, +) { + let transaction = Transaction::new_signed_with_payer( + &[spl_token_2022::instruction::transfer_checked( + program_id, + source, + mint, + destination, + &authority.pubkey(), + &[], + amount, + decimals, + ) + .unwrap()], + Some(&payer.pubkey()), + &[payer, authority], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_token_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + account: &Keypair, + pool_mint: &Pubkey, + authority: &Keypair, + extensions: &[ExtensionType], +) -> Result<(), TransportError> { + let rent = banks_client.get_rent().await.unwrap(); + let space = ExtensionType::try_calculate_account_len::(extensions).unwrap(); + let account_rent = rent.minimum_balance(space); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &account.pubkey(), + account_rent, + space as u64, + program_id, + )]; + + for extension in extensions { + match extension { + ExtensionType::ImmutableOwner => instructions.push( + spl_token_2022::instruction::initialize_immutable_owner( + program_id, + &account.pubkey(), + ) + .unwrap(), + ), + ExtensionType::TransferFeeAmount + | ExtensionType::MemoTransfer + | ExtensionType::CpiGuard + | ExtensionType::NonTransferableAccount => (), + _ => unimplemented!(), + }; + } + + instructions.push( + spl_token_2022::instruction::initialize_account( + program_id, + &account.pubkey(), + pool_mint, + &authority.pubkey(), + ) + .unwrap(), + ); + + let mut signers = vec![payer, account]; + for extension in extensions { + match extension { + ExtensionType::MemoTransfer => { + signers.push(authority); + instructions.push( + spl_token_2022::extension::memo_transfer::instruction::enable_required_transfer_memos( + program_id, + &account.pubkey(), + &authority.pubkey(), + &[], + ) + .unwrap() + ) + } + ExtensionType::CpiGuard => { + signers.push(authority); + instructions.push( + spl_token_2022::extension::cpi_guard::instruction::enable_cpi_guard( + program_id, + &account.pubkey(), + &authority.pubkey(), + &[], + ) + .unwrap(), + ) + } + ExtensionType::ImmutableOwner + | ExtensionType::TransferFeeAmount + | ExtensionType::NonTransferableAccount => (), + _ => unimplemented!(), + } + } + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +pub async fn close_token_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + account: &Pubkey, + lamports_destination: &Pubkey, + manager: &Keypair, +) -> Result<(), TransportError> { + let mut transaction = Transaction::new_with_payer( + &[spl_token_2022::instruction::close_account( + program_id, + account, + lamports_destination, + &manager.pubkey(), + &[], + ) + .unwrap()], + Some(&payer.pubkey()), + ); + transaction.sign(&[payer, manager], *recent_blockhash); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +pub async fn freeze_token_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + account: &Pubkey, + pool_mint: &Pubkey, + manager: &Keypair, +) -> Result<(), TransportError> { + let mut transaction = Transaction::new_with_payer( + &[spl_token_2022::instruction::freeze_account( + program_id, + account, + pool_mint, + &manager.pubkey(), + &[], + ) + .unwrap()], + Some(&payer.pubkey()), + ); + transaction.sign(&[payer, manager], *recent_blockhash); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn mint_tokens( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + mint: &Pubkey, + account: &Pubkey, + mint_authority: &Keypair, + amount: u64, +) -> Result<(), TransportError> { + let transaction = Transaction::new_signed_with_payer( + &[spl_token_2022::instruction::mint_to( + program_id, + mint, + account, + &mint_authority.pubkey(), + &[], + amount, + ) + .unwrap()], + Some(&payer.pubkey()), + &[payer, mint_authority], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn burn_tokens( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + mint: &Pubkey, + account: &Pubkey, + authority: &Keypair, + amount: u64, +) -> Result<(), TransportError> { + let transaction = Transaction::new_signed_with_payer( + &[spl_token_2022::instruction::burn( + program_id, + account, + mint, + &authority.pubkey(), + &[], + amount, + ) + .unwrap()], + Some(&payer.pubkey()), + &[payer, authority], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +pub async fn get_token_balance(banks_client: &mut BanksClient, token: &Pubkey) -> u64 { + let token_account = banks_client.get_account(*token).await.unwrap().unwrap(); + let account_info = StateWithExtensionsOwned::::unpack(token_account.data).unwrap(); + account_info.base.amount +} + +#[derive(Clone, BorshDeserialize, Debug, PartialEq, Eq)] +pub struct Metadata { + pub key: u8, + pub update_authority: Pubkey, + pub mint: Pubkey, + pub name: String, + pub symbol: String, + pub uri: String, + pub seller_fee_basis_points: u16, + pub creators: Option>, + pub primary_sale_happened: bool, + pub is_mutable: bool, +} + +pub async fn get_metadata_account(banks_client: &mut BanksClient, token_mint: &Pubkey) -> Metadata { + let (token_metadata, _) = find_metadata_account(token_mint); + let token_metadata_account = banks_client + .get_account(token_metadata) + .await + .unwrap() + .unwrap(); + try_from_slice_unchecked(token_metadata_account.data.as_slice()).unwrap() +} + +pub async fn get_token_supply(banks_client: &mut BanksClient, mint: &Pubkey) -> u64 { + let mint_account = banks_client.get_account(*mint).await.unwrap().unwrap(); + let account_info = StateWithExtensionsOwned::::unpack(mint_account.data).unwrap(); + account_info.base.supply +} + +#[allow(clippy::too_many_arguments)] +pub async fn delegate_tokens( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + account: &Pubkey, + manager: &Keypair, + delegate: &Pubkey, + amount: u64, +) { + let transaction = Transaction::new_signed_with_payer( + &[spl_token_2022::instruction::approve( + program_id, + account, + delegate, + &manager.pubkey(), + &[], + amount, + ) + .unwrap()], + Some(&payer.pubkey()), + &[payer, manager], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); +} + +pub async fn revoke_tokens( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + program_id: &Pubkey, + account: &Pubkey, + manager: &Keypair, +) { + let transaction = Transaction::new_signed_with_payer( + &[ + spl_token_2022::instruction::revoke(program_id, account, &manager.pubkey(), &[]) + .unwrap(), + ], + Some(&payer.pubkey()), + &[payer, manager], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_stake_pool( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool: &Keypair, + validator_list: &Keypair, + reserve_stake: &Pubkey, + token_program_id: &Pubkey, + pool_mint: &Pubkey, + pool_token_account: &Pubkey, + manager: &Keypair, + staker: &Pubkey, + withdraw_authority: &Pubkey, + stake_deposit_authority: &Option, + epoch_fee: &state::Fee, + withdrawal_fee: &state::Fee, + deposit_fee: &state::Fee, + referral_fee: u8, + sol_deposit_fee: &state::Fee, + sol_referral_fee: u8, + max_validators: u32, +) -> Result<(), TransportError> { + let rent = banks_client.get_rent().await.unwrap(); + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = + get_instance_packed_len(&state::ValidatorList::new(max_validators)).unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool.pubkey(), + &manager.pubkey(), + staker, + withdraw_authority, + &validator_list.pubkey(), + reserve_stake, + pool_mint, + pool_token_account, + token_program_id, + stake_deposit_authority.as_ref().map(|k| k.pubkey()), + *epoch_fee, + *withdrawal_fee, + *deposit_fee, + referral_fee, + max_validators, + ), + instruction::set_fee( + &id(), + &stake_pool.pubkey(), + &manager.pubkey(), + FeeType::SolDeposit(*sol_deposit_fee), + ), + instruction::set_fee( + &id(), + &stake_pool.pubkey(), + &manager.pubkey(), + FeeType::SolReferral(sol_referral_fee), + ), + ], + Some(&payer.pubkey()), + ); + let mut signers = vec![payer, stake_pool, validator_list, manager]; + if let Some(stake_deposit_authority) = stake_deposit_authority.as_ref() { + signers.push(stake_deposit_authority); + } + transaction.sign(&signers, *recent_blockhash); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) +} + +pub async fn create_vote( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator: &Keypair, + vote: &Keypair, +) { + let rent = banks_client.get_rent().await.unwrap(); + let rent_voter = rent.minimum_balance(VoteState::size_of()); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &validator.pubkey(), + rent.minimum_balance(0), + 0, + &system_program::id(), + )]; + instructions.append(&mut vote_instruction::create_account_with_config( + &payer.pubkey(), + &vote.pubkey(), + &VoteInit { + node_pubkey: validator.pubkey(), + authorized_voter: validator.pubkey(), + ..VoteInit::default() + }, + rent_voter, + vote_instruction::CreateVoteAccountConfig { + space: VoteState::size_of() as u64, + ..Default::default() + }, + )); + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[validator, vote, payer], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); +} + +pub async fn create_independent_stake_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Keypair, + authorized: &stake::state::Authorized, + lockup: &stake::state::Lockup, + stake_amount: u64, +) -> u64 { + let rent = banks_client.get_rent().await.unwrap(); + let lamports = + rent.minimum_balance(std::mem::size_of::()) + stake_amount; + + let transaction = Transaction::new_signed_with_payer( + &stake::instruction::create_account( + &payer.pubkey(), + &stake.pubkey(), + authorized, + lockup, + lamports, + ), + Some(&payer.pubkey()), + &[payer, stake], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + lamports +} + +pub async fn create_blank_stake_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Keypair, +) -> u64 { + let rent = banks_client.get_rent().await.unwrap(); + let lamports = rent.minimum_balance(std::mem::size_of::()); + + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &payer.pubkey(), + &stake.pubkey(), + lamports, + std::mem::size_of::() as u64, + &stake::program::id(), + )], + Some(&payer.pubkey()), + &[payer, stake], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + lamports +} + +pub async fn delegate_stake_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + authorized: &Keypair, + vote: &Pubkey, +) { + let mut transaction = Transaction::new_with_payer( + &[stake::instruction::delegate_stake( + stake, + &authorized.pubkey(), + vote, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[payer, authorized], *recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); +} + +pub async fn stake_get_minimum_delegation( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, +) -> u64 { + let transaction = Transaction::new_signed_with_payer( + &[stake::instruction::get_minimum_delegation()], + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + let mut data = banks_client + .simulate_transaction(transaction) + .await + .unwrap() + .simulation_details + .unwrap() + .return_data + .unwrap() + .data; + data.resize(8, 0); + data.try_into().map(u64::from_le_bytes).unwrap() +} + +pub async fn stake_pool_get_minimum_delegation( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, +) -> u64 { + let stake_minimum = stake_get_minimum_delegation(banks_client, payer, recent_blockhash).await; + minimum_delegation(stake_minimum) +} + +pub async fn authorize_stake_account( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + authorized: &Keypair, + new_authorized: &Pubkey, + stake_authorize: stake::state::StakeAuthorize, +) { + let mut transaction = Transaction::new_with_payer( + &[stake::instruction::authorize( + stake, + &authorized.pubkey(), + new_authorized, + stake_authorize, + None, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[payer, authorized], *recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); +} + +pub async fn create_unknown_validator_stake( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool: &Pubkey, + lamports: u64, +) -> ValidatorStakeAccount { + let mut unknown_stake = ValidatorStakeAccount::new(stake_pool, NonZeroU32::new(1), 222); + create_vote( + banks_client, + payer, + recent_blockhash, + &unknown_stake.validator, + &unknown_stake.vote, + ) + .await; + let user = Keypair::new(); + let fake_validator_stake = Keypair::new(); + let stake_minimum_delegation = + stake_get_minimum_delegation(banks_client, payer, recent_blockhash).await; + let current_minimum_delegation = minimum_delegation(stake_minimum_delegation); + create_independent_stake_account( + banks_client, + payer, + recent_blockhash, + &fake_validator_stake, + &stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }, + &stake::state::Lockup::default(), + current_minimum_delegation + lamports, + ) + .await; + delegate_stake_account( + banks_client, + payer, + recent_blockhash, + &fake_validator_stake.pubkey(), + &user, + &unknown_stake.vote.pubkey(), + ) + .await; + unknown_stake.stake_account = fake_validator_stake.pubkey(); + unknown_stake +} + +pub struct ValidatorStakeAccount { + pub stake_account: Pubkey, + pub transient_stake_account: Pubkey, + pub transient_stake_seed: u64, + pub validator_stake_seed: Option, + pub vote: Keypair, + pub validator: Keypair, + pub stake_pool: Pubkey, +} + +impl ValidatorStakeAccount { + pub fn new( + stake_pool: &Pubkey, + validator_stake_seed: Option, + transient_stake_seed: u64, + ) -> Self { + let validator = Keypair::new(); + let vote = Keypair::new(); + let (stake_account, _) = + find_stake_program_address(&id(), &vote.pubkey(), stake_pool, validator_stake_seed); + let (transient_stake_account, _) = find_transient_stake_program_address( + &id(), + &vote.pubkey(), + stake_pool, + transient_stake_seed, + ); + ValidatorStakeAccount { + stake_account, + transient_stake_account, + transient_stake_seed, + validator_stake_seed, + vote, + validator, + stake_pool: *stake_pool, + } + } +} + +pub struct StakePoolAccounts { + pub stake_pool: Keypair, + pub validator_list: Keypair, + pub reserve_stake: Keypair, + pub token_program_id: Pubkey, + pub pool_mint: Keypair, + pub pool_fee_account: Keypair, + pub pool_decimals: u8, + pub manager: Keypair, + pub staker: Keypair, + pub withdraw_authority: Pubkey, + pub stake_deposit_authority: Pubkey, + pub stake_deposit_authority_keypair: Option, + pub epoch_fee: state::Fee, + pub withdrawal_fee: state::Fee, + pub deposit_fee: state::Fee, + pub referral_fee: u8, + pub sol_deposit_fee: state::Fee, + pub sol_referral_fee: u8, + pub max_validators: u32, + pub compute_unit_limit: Option, +} + +impl StakePoolAccounts { + pub fn new_with_deposit_authority(stake_deposit_authority: Keypair) -> Self { + Self { + stake_deposit_authority: stake_deposit_authority.pubkey(), + stake_deposit_authority_keypair: Some(stake_deposit_authority), + ..Default::default() + } + } + + pub fn new_with_token_program(token_program_id: Pubkey) -> Self { + Self { + token_program_id, + ..Default::default() + } + } + + pub fn calculate_fee(&self, amount: u64) -> u64 { + (amount * self.epoch_fee.numerator + self.epoch_fee.denominator - 1) + / self.epoch_fee.denominator + } + + pub fn calculate_withdrawal_fee(&self, pool_tokens: u64) -> u64 { + (pool_tokens * self.withdrawal_fee.numerator + self.withdrawal_fee.denominator - 1) + / self.withdrawal_fee.denominator + } + + pub fn calculate_inverse_withdrawal_fee(&self, pool_tokens: u64) -> u64 { + (pool_tokens * self.withdrawal_fee.denominator + self.withdrawal_fee.denominator - 1) + / (self.withdrawal_fee.denominator - self.withdrawal_fee.numerator) + } + + pub fn calculate_referral_fee(&self, deposit_fee_collected: u64) -> u64 { + deposit_fee_collected * self.referral_fee as u64 / 100 + } + + pub fn calculate_sol_deposit_fee(&self, pool_tokens: u64) -> u64 { + (pool_tokens * self.sol_deposit_fee.numerator + self.sol_deposit_fee.denominator - 1) + / self.sol_deposit_fee.denominator + } + + pub fn calculate_sol_referral_fee(&self, deposit_fee_collected: u64) -> u64 { + deposit_fee_collected * self.sol_referral_fee as u64 / 100 + } + + pub async fn initialize_stake_pool( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + reserve_lamports: u64, + ) -> Result<(), TransportError> { + create_mint( + banks_client, + payer, + recent_blockhash, + &self.token_program_id, + &self.pool_mint, + &self.withdraw_authority, + self.pool_decimals, + &[], + ) + .await?; + create_token_account( + banks_client, + payer, + recent_blockhash, + &self.token_program_id, + &self.pool_fee_account, + &self.pool_mint.pubkey(), + &self.manager, + &[], + ) + .await?; + create_independent_stake_account( + banks_client, + payer, + recent_blockhash, + &self.reserve_stake, + &stake::state::Authorized { + staker: self.withdraw_authority, + withdrawer: self.withdraw_authority, + }, + &stake::state::Lockup::default(), + reserve_lamports, + ) + .await; + create_stake_pool( + banks_client, + payer, + recent_blockhash, + &self.stake_pool, + &self.validator_list, + &self.reserve_stake.pubkey(), + &self.token_program_id, + &self.pool_mint.pubkey(), + &self.pool_fee_account.pubkey(), + &self.manager, + &self.staker.pubkey(), + &self.withdraw_authority, + &self.stake_deposit_authority_keypair, + &self.epoch_fee, + &self.withdrawal_fee, + &self.deposit_fee, + self.referral_fee, + &self.sol_deposit_fee, + self.sol_referral_fee, + self.max_validators, + ) + .await?; + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_stake_with_slippage( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + pool_account: &Pubkey, + validator_stake_account: &Pubkey, + current_staker: &Keypair, + minimum_pool_tokens_out: u64, + ) -> Option { + let mut instructions = instruction::deposit_stake_with_slippage( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + &self.withdraw_authority, + stake, + ¤t_staker.pubkey(), + validator_stake_account, + &self.reserve_stake.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + minimum_pool_tokens_out, + ); + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, current_staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_stake( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + pool_account: &Pubkey, + validator_stake_account: &Pubkey, + current_staker: &Keypair, + ) -> Option { + self.deposit_stake_with_referral( + banks_client, + payer, + recent_blockhash, + stake, + pool_account, + validator_stake_account, + current_staker, + &self.pool_fee_account.pubkey(), + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_stake_with_referral( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + pool_account: &Pubkey, + validator_stake_account: &Pubkey, + current_staker: &Keypair, + referrer: &Pubkey, + ) -> Option { + let mut signers = vec![payer, current_staker]; + let mut instructions = + if let Some(stake_deposit_authority) = self.stake_deposit_authority_keypair.as_ref() { + signers.push(stake_deposit_authority); + instruction::deposit_stake_with_authority( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + &self.stake_deposit_authority, + &self.withdraw_authority, + stake, + ¤t_staker.pubkey(), + validator_stake_account, + &self.reserve_stake.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + referrer, + &self.pool_mint.pubkey(), + &self.token_program_id, + ) + } else { + instruction::deposit_stake( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + &self.withdraw_authority, + stake, + ¤t_staker.pubkey(), + validator_stake_account, + &self.reserve_stake.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + referrer, + &self.pool_mint.pubkey(), + &self.token_program_id, + ) + }; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_sol( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + pool_account: &Pubkey, + amount: u64, + sol_deposit_authority: Option<&Keypair>, + ) -> Option { + let mut signers = vec![payer]; + let instruction = if let Some(sol_deposit_authority) = sol_deposit_authority { + signers.push(sol_deposit_authority); + instruction::deposit_sol_with_authority( + &id(), + &self.stake_pool.pubkey(), + &sol_deposit_authority.pubkey(), + &self.withdraw_authority, + &self.reserve_stake.pubkey(), + &payer.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount, + ) + } else { + instruction::deposit_sol( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.reserve_stake.pubkey(), + &payer.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount, + ) + }; + let mut instructions = vec![instruction]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_sol_with_slippage( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + pool_account: &Pubkey, + lamports_in: u64, + minimum_pool_tokens_out: u64, + ) -> Option { + let mut instructions = vec![instruction::deposit_sol_with_slippage( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.reserve_stake.pubkey(), + &payer.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + lamports_in, + minimum_pool_tokens_out, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn withdraw_stake_with_slippage( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_recipient: &Pubkey, + user_transfer_authority: &Keypair, + pool_account: &Pubkey, + validator_stake_account: &Pubkey, + recipient_new_authority: &Pubkey, + pool_tokens_in: u64, + minimum_lamports_out: u64, + ) -> Option { + let mut instructions = vec![instruction::withdraw_stake_with_slippage( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + &self.withdraw_authority, + validator_stake_account, + stake_recipient, + recipient_new_authority, + &user_transfer_authority.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + pool_tokens_in, + minimum_lamports_out, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, user_transfer_authority], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn withdraw_stake( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_recipient: &Pubkey, + user_transfer_authority: &Keypair, + pool_account: &Pubkey, + validator_stake_account: &Pubkey, + recipient_new_authority: &Pubkey, + amount: u64, + ) -> Option { + let mut instructions = vec![instruction::withdraw_stake( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + &self.withdraw_authority, + validator_stake_account, + stake_recipient, + recipient_new_authority, + &user_transfer_authority.pubkey(), + pool_account, + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, user_transfer_authority], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn withdraw_sol_with_slippage( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + user: &Keypair, + pool_account: &Pubkey, + amount_in: u64, + minimum_lamports_out: u64, + ) -> Option { + let mut instructions = vec![instruction::withdraw_sol_with_slippage( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &user.pubkey(), + pool_account, + &self.reserve_stake.pubkey(), + &user.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount_in, + minimum_lamports_out, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, user], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn withdraw_sol( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + user: &Keypair, + pool_account: &Pubkey, + amount: u64, + sol_withdraw_authority: Option<&Keypair>, + ) -> Option { + let mut signers = vec![payer, user]; + let instruction = if let Some(sol_withdraw_authority) = sol_withdraw_authority { + signers.push(sol_withdraw_authority); + instruction::withdraw_sol_with_authority( + &id(), + &self.stake_pool.pubkey(), + &sol_withdraw_authority.pubkey(), + &self.withdraw_authority, + &user.pubkey(), + pool_account, + &self.reserve_stake.pubkey(), + &user.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount, + ) + } else { + instruction::withdraw_sol( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &user.pubkey(), + pool_account, + &self.reserve_stake.pubkey(), + &user.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + amount, + ) + }; + let mut instructions = vec![instruction]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub async fn get_stake_pool(&self, banks_client: &mut BanksClient) -> StakePool { + let stake_pool_account = get_account(banks_client, &self.stake_pool.pubkey()).await; + try_from_slice_unchecked::(stake_pool_account.data.as_slice()).unwrap() + } + + pub async fn get_validator_list(&self, banks_client: &mut BanksClient) -> ValidatorList { + let validator_list_account = get_account(banks_client, &self.validator_list.pubkey()).await; + try_from_slice_unchecked::(validator_list_account.data.as_slice()).unwrap() + } + + pub async fn update_validator_list_balance( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + len: usize, + no_merge: bool, + ) -> Option { + let validator_list = self.get_validator_list(banks_client).await; + let mut instructions = vec![instruction::update_validator_list_balance_chunk( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + &validator_list, + len, + 0, + no_merge, + ) + .unwrap()]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub async fn update_stake_pool_balance( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + ) -> Option { + let mut instructions = vec![instruction::update_stake_pool_balance( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub async fn cleanup_removed_validator_entries( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + ) -> Option { + let mut instructions = vec![instruction::cleanup_removed_validator_entries( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub async fn update_all( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + no_merge: bool, + ) -> Option { + let validator_list = self.get_validator_list(banks_client).await; + let mut instructions = vec![]; + for (i, chunk) in validator_list + .validators + .chunks(MAX_VALIDATORS_TO_UPDATE) + .enumerate() + { + instructions.push( + instruction::update_validator_list_balance_chunk( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + &validator_list, + chunk.len(), + i * MAX_VALIDATORS_TO_UPDATE, + no_merge, + ) + .unwrap(), + ); + } + instructions.extend([ + instruction::update_stake_pool_balance( + &id(), + &self.stake_pool.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + &self.pool_fee_account.pubkey(), + &self.pool_mint.pubkey(), + &self.token_program_id, + ), + instruction::cleanup_removed_validator_entries( + &id(), + &self.stake_pool.pubkey(), + &self.validator_list.pubkey(), + ), + ]); + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub async fn add_validator_to_pool( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake: &Pubkey, + validator: &Pubkey, + seed: Option, + ) -> Option { + let mut instructions = vec![instruction::add_validator_to_pool( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.reserve_stake.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + stake, + validator, + seed, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn remove_validator_from_pool( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + ) -> Option { + let mut instructions = vec![instruction::remove_validator_from_pool( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + validator_stake, + transient_stake, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn decrease_validator_stake_deprecated( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ) -> Option { + #[allow(deprecated)] + let mut instructions = vec![ + system_instruction::transfer( + &payer.pubkey(), + transient_stake, + STAKE_ACCOUNT_RENT_EXEMPTION, + ), + instruction::decrease_validator_stake( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + validator_stake, + transient_stake, + lamports, + transient_stake_seed, + ), + ]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn decrease_validator_stake_with_reserve( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ) -> Option { + let mut instructions = vec![instruction::decrease_validator_stake_with_reserve( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + validator_stake, + transient_stake, + lamports, + transient_stake_seed, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn decrease_additional_validator_stake( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_stake: &Pubkey, + ephemeral_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, + ) -> Option { + let mut instructions = vec![instruction::decrease_additional_validator_stake( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + validator_stake, + ephemeral_stake, + transient_stake, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn decrease_validator_stake_either( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_stake: &Pubkey, + transient_stake: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + instruction_type: DecreaseInstruction, + ) -> Option { + match instruction_type { + DecreaseInstruction::Additional => { + let ephemeral_stake_seed = 0; + let ephemeral_stake = find_ephemeral_stake_program_address( + &id(), + &self.stake_pool.pubkey(), + ephemeral_stake_seed, + ) + .0; + self.decrease_additional_validator_stake( + banks_client, + payer, + recent_blockhash, + validator_stake, + &ephemeral_stake, + transient_stake, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + ) + .await + } + DecreaseInstruction::Reserve => { + self.decrease_validator_stake_with_reserve( + banks_client, + payer, + recent_blockhash, + validator_stake, + transient_stake, + lamports, + transient_stake_seed, + ) + .await + } + DecreaseInstruction::Deprecated => + { + #[allow(deprecated)] + self.decrease_validator_stake_deprecated( + banks_client, + payer, + recent_blockhash, + validator_stake, + transient_stake, + lamports, + transient_stake_seed, + ) + .await + } + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn increase_validator_stake( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + transient_stake: &Pubkey, + validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ) -> Option { + let mut instructions = vec![instruction::increase_validator_stake( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + transient_stake, + validator_stake, + validator, + lamports, + transient_stake_seed, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn increase_additional_validator_stake( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + ephemeral_stake: &Pubkey, + transient_stake: &Pubkey, + validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + ephemeral_stake_seed: u64, + ) -> Option { + let mut instructions = vec![instruction::increase_additional_validator_stake( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.withdraw_authority, + &self.validator_list.pubkey(), + &self.reserve_stake.pubkey(), + ephemeral_stake, + transient_stake, + validator_stake, + validator, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + #[allow(clippy::too_many_arguments)] + pub async fn increase_validator_stake_either( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + transient_stake: &Pubkey, + validator_stake: &Pubkey, + validator: &Pubkey, + lamports: u64, + transient_stake_seed: u64, + use_additional_instruction: bool, + ) -> Option { + if use_additional_instruction { + let ephemeral_stake_seed = 0; + let ephemeral_stake = find_ephemeral_stake_program_address( + &id(), + &self.stake_pool.pubkey(), + ephemeral_stake_seed, + ) + .0; + self.increase_additional_validator_stake( + banks_client, + payer, + recent_blockhash, + &ephemeral_stake, + transient_stake, + validator_stake, + validator, + lamports, + transient_stake_seed, + ephemeral_stake_seed, + ) + .await + } else { + self.increase_validator_stake( + banks_client, + payer, + recent_blockhash, + transient_stake, + validator_stake, + validator, + lamports, + transient_stake_seed, + ) + .await + } + } + + pub async fn set_preferred_validator( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator_type: instruction::PreferredValidatorType, + validator: Option, + ) -> Option { + let mut instructions = vec![instruction::set_preferred_validator( + &id(), + &self.stake_pool.pubkey(), + &self.staker.pubkey(), + &self.validator_list.pubkey(), + validator_type, + validator, + )]; + self.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, &self.staker], + *recent_blockhash, + ); + banks_client + .process_transaction(transaction) + .await + .map_err(|e| e.into()) + .err() + } + + pub fn state(&self) -> (state::StakePool, state::ValidatorList) { + let (_, stake_withdraw_bump_seed) = + find_withdraw_authority_program_address(&id(), &self.stake_pool.pubkey()); + let stake_pool = state::StakePool { + account_type: state::AccountType::StakePool, + manager: self.manager.pubkey(), + staker: self.staker.pubkey(), + stake_deposit_authority: self.stake_deposit_authority, + stake_withdraw_bump_seed, + validator_list: self.validator_list.pubkey(), + reserve_stake: self.reserve_stake.pubkey(), + pool_mint: self.pool_mint.pubkey(), + manager_fee_account: self.pool_fee_account.pubkey(), + token_program_id: self.token_program_id, + total_lamports: 0, + pool_token_supply: 0, + last_update_epoch: 0, + lockup: stake::state::Lockup::default(), + epoch_fee: self.epoch_fee, + next_epoch_fee: FutureEpoch::None, + preferred_deposit_validator_vote_address: None, + preferred_withdraw_validator_vote_address: None, + stake_deposit_fee: state::Fee::default(), + sol_deposit_fee: state::Fee::default(), + stake_withdrawal_fee: state::Fee::default(), + next_stake_withdrawal_fee: FutureEpoch::None, + stake_referral_fee: 0, + sol_referral_fee: 0, + sol_deposit_authority: None, + sol_withdraw_authority: None, + sol_withdrawal_fee: state::Fee::default(), + next_sol_withdrawal_fee: FutureEpoch::None, + last_epoch_pool_token_supply: 0, + last_epoch_total_lamports: 0, + }; + let mut validator_list = ValidatorList::new(self.max_validators); + validator_list.validators = vec![]; + (stake_pool, validator_list) + } + + pub fn maybe_add_compute_budget_instruction(&self, instructions: &mut Vec) { + if let Some(compute_unit_limit) = self.compute_unit_limit { + instructions.insert( + 0, + ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit), + ); + } + } +} +impl Default for StakePoolAccounts { + fn default() -> Self { + let stake_pool = Keypair::new(); + let validator_list = Keypair::new(); + let stake_pool_address = &stake_pool.pubkey(); + let (stake_deposit_authority, _) = + find_deposit_authority_program_address(&id(), stake_pool_address); + let (withdraw_authority, _) = + find_withdraw_authority_program_address(&id(), stake_pool_address); + let reserve_stake = Keypair::new(); + let pool_mint = Keypair::new(); + let pool_fee_account = Keypair::new(); + let manager = Keypair::new(); + let staker = Keypair::new(); + + Self { + stake_pool, + validator_list, + reserve_stake, + token_program_id: spl_token::id(), + pool_mint, + pool_fee_account, + pool_decimals: native_mint::DECIMALS, + manager, + staker, + withdraw_authority, + stake_deposit_authority, + stake_deposit_authority_keypair: None, + epoch_fee: state::Fee { + numerator: 1, + denominator: 100, + }, + withdrawal_fee: state::Fee { + numerator: 3, + denominator: 1000, + }, + deposit_fee: state::Fee { + numerator: 1, + denominator: 1000, + }, + referral_fee: 25, + sol_deposit_fee: state::Fee { + numerator: 3, + denominator: 100, + }, + sol_referral_fee: 50, + max_validators: MAX_TEST_VALIDATORS, + compute_unit_limit: None, + } + } +} + +pub async fn simple_add_validator_to_pool( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool_accounts: &StakePoolAccounts, + sol_deposit_authority: Option<&Keypair>, +) -> ValidatorStakeAccount { + let validator_stake = ValidatorStakeAccount::new( + &stake_pool_accounts.stake_pool.pubkey(), + DEFAULT_VALIDATOR_STAKE_SEED, + DEFAULT_TRANSIENT_STAKE_SEED, + ); + + let rent = banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = + stake_pool_get_minimum_delegation(banks_client, payer, recent_blockhash).await; + + let pool_token_account = Keypair::new(); + create_token_account( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + payer, + &[], + ) + .await + .unwrap(); + let error = stake_pool_accounts + .deposit_sol( + banks_client, + payer, + recent_blockhash, + &pool_token_account.pubkey(), + stake_rent + current_minimum_delegation, + sol_deposit_authority, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + create_vote( + banks_client, + payer, + recent_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + banks_client, + payer, + recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + validator_stake +} + +#[derive(Debug)] +pub struct DepositStakeAccount { + pub authority: Keypair, + pub stake: Keypair, + pub pool_account: Keypair, + pub stake_lamports: u64, + pub pool_tokens: u64, + pub vote_account: Pubkey, + pub validator_stake_account: Pubkey, +} + +impl DepositStakeAccount { + pub fn new_with_vote( + vote_account: Pubkey, + validator_stake_account: Pubkey, + stake_lamports: u64, + ) -> Self { + let authority = Keypair::new(); + let stake = Keypair::new(); + let pool_account = Keypair::new(); + Self { + authority, + stake, + pool_account, + vote_account, + validator_stake_account, + stake_lamports, + pool_tokens: 0, + } + } + + pub async fn create_and_delegate( + &self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + ) { + let lockup = stake::state::Lockup::default(); + let authorized = stake::state::Authorized { + staker: self.authority.pubkey(), + withdrawer: self.authority.pubkey(), + }; + create_independent_stake_account( + banks_client, + payer, + recent_blockhash, + &self.stake, + &authorized, + &lockup, + self.stake_lamports, + ) + .await; + delegate_stake_account( + banks_client, + payer, + recent_blockhash, + &self.stake.pubkey(), + &self.authority, + &self.vote_account, + ) + .await; + } + + pub async fn deposit_stake( + &mut self, + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool_accounts: &StakePoolAccounts, + ) { + // make pool token account + create_token_account( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.token_program_id, + &self.pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &self.authority, + &[], + ) + .await + .unwrap(); + + let error = stake_pool_accounts + .deposit_stake( + banks_client, + payer, + recent_blockhash, + &self.stake.pubkey(), + &self.pool_account.pubkey(), + &self.validator_stake_account, + &self.authority, + ) + .await; + self.pool_tokens = get_token_balance(banks_client, &self.pool_account.pubkey()).await; + assert!(error.is_none(), "{:?}", error); + } +} + +pub async fn simple_deposit_stake( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool_accounts: &StakePoolAccounts, + validator_stake_account: &ValidatorStakeAccount, + stake_lamports: u64, +) -> Option { + let authority = Keypair::new(); + // make stake account + let stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + let authorized = stake::state::Authorized { + staker: authority.pubkey(), + withdrawer: authority.pubkey(), + }; + create_independent_stake_account( + banks_client, + payer, + recent_blockhash, + &stake, + &authorized, + &lockup, + stake_lamports, + ) + .await; + let vote_account = validator_stake_account.vote.pubkey(); + delegate_stake_account( + banks_client, + payer, + recent_blockhash, + &stake.pubkey(), + &authority, + &vote_account, + ) + .await; + // make pool token account + let pool_account = Keypair::new(); + create_token_account( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_account, + &stake_pool_accounts.pool_mint.pubkey(), + &authority, + &[], + ) + .await + .unwrap(); + + let validator_stake_account = validator_stake_account.stake_account; + let error = stake_pool_accounts + .deposit_stake( + banks_client, + payer, + recent_blockhash, + &stake.pubkey(), + &pool_account.pubkey(), + &validator_stake_account, + &authority, + ) + .await; + // backwards, but oh well! + if error.is_some() { + return None; + } + + let pool_tokens = get_token_balance(banks_client, &pool_account.pubkey()).await; + + Some(DepositStakeAccount { + authority, + stake, + pool_account, + stake_lamports, + pool_tokens, + vote_account, + validator_stake_account, + }) +} + +pub async fn get_validator_list_sum( + banks_client: &mut BanksClient, + reserve_stake: &Pubkey, + validator_list: &Pubkey, +) -> u64 { + let validator_list = banks_client + .get_account(*validator_list) + .await + .unwrap() + .unwrap(); + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let reserve_stake = banks_client + .get_account(*reserve_stake) + .await + .unwrap() + .unwrap(); + + let validator_sum: u64 = validator_list + .validators + .iter() + .map(|info| info.stake_lamports().unwrap()) + .sum(); + let rent = banks_client.get_rent().await.unwrap(); + let rent = rent.minimum_balance(std::mem::size_of::()); + validator_sum + reserve_stake.lamports - rent - MINIMUM_RESERVE_LAMPORTS +} + +pub fn add_vote_account_with_pubkey( + voter_pubkey: &Pubkey, + program_test: &mut ProgramTest, +) -> Pubkey { + let authorized_voter = Pubkey::new_unique(); + let authorized_withdrawer = Pubkey::new_unique(); + let commission = 1; + + // create vote account + let node_pubkey = Pubkey::new_unique(); + let vote_state = VoteStateVersions::new_current(VoteState::new( + &VoteInit { + node_pubkey, + authorized_voter, + authorized_withdrawer, + commission, + }, + &Clock::default(), + )); + let vote_account = SolanaAccount::create( + ACCOUNT_RENT_EXEMPTION, + bincode::serialize::(&vote_state).unwrap(), + solana_vote_program::id(), + false, + Epoch::default(), + ); + program_test.add_account(*voter_pubkey, vote_account); + *voter_pubkey +} + +pub fn add_vote_account(program_test: &mut ProgramTest) -> Pubkey { + let voter_pubkey = Pubkey::new_unique(); + add_vote_account_with_pubkey(&voter_pubkey, program_test) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_validator_stake_account( + program_test: &mut ProgramTest, + stake_pool: &mut state::StakePool, + validator_list: &mut state::ValidatorList, + stake_pool_pubkey: &Pubkey, + withdraw_authority: &Pubkey, + voter_pubkey: &Pubkey, + stake_amount: u64, + status: state::StakeStatus, +) { + let meta = stake::state::Meta { + rent_exempt_reserve: STAKE_ACCOUNT_RENT_EXEMPTION, + authorized: stake::state::Authorized { + staker: *withdraw_authority, + withdrawer: *withdraw_authority, + }, + lockup: stake_pool.lockup, + }; + + // create validator stake account + let stake = stake::state::Stake { + delegation: stake::state::Delegation { + voter_pubkey: *voter_pubkey, + stake: stake_amount, + activation_epoch: FIRST_NORMAL_EPOCH, + deactivation_epoch: u64::MAX, + ..Default::default() + }, + credits_observed: 0, + }; + + let mut data = vec![0u8; std::mem::size_of::()]; + let stake_data = bincode::serialize(&stake::state::StakeStateV2::Stake( + meta, + stake, + stake::stake_flags::StakeFlags::empty(), + )) + .unwrap(); + data[..stake_data.len()].copy_from_slice(&stake_data); + let stake_account = SolanaAccount::create( + stake_amount + STAKE_ACCOUNT_RENT_EXEMPTION, + data, + stake::program::id(), + false, + Epoch::default(), + ); + + let raw_suffix = 0; + let validator_seed_suffix = NonZeroU32::new(raw_suffix); + let (stake_address, _) = find_stake_program_address( + &id(), + voter_pubkey, + stake_pool_pubkey, + validator_seed_suffix, + ); + program_test.add_account(stake_address, stake_account); + + let active_stake_lamports = stake_amount + STAKE_ACCOUNT_RENT_EXEMPTION; + + validator_list.validators.push(state::ValidatorStakeInfo { + status: status.into(), + vote_account_address: *voter_pubkey, + active_stake_lamports: active_stake_lamports.into(), + transient_stake_lamports: 0.into(), + last_update_epoch: FIRST_NORMAL_EPOCH.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: raw_suffix.into(), + }); + + stake_pool.total_lamports += active_stake_lamports; + stake_pool.pool_token_supply += active_stake_lamports; +} + +pub fn add_reserve_stake_account( + program_test: &mut ProgramTest, + reserve_stake: &Pubkey, + withdraw_authority: &Pubkey, + stake_amount: u64, +) { + let meta = stake::state::Meta { + rent_exempt_reserve: STAKE_ACCOUNT_RENT_EXEMPTION, + authorized: stake::state::Authorized { + staker: *withdraw_authority, + withdrawer: *withdraw_authority, + }, + lockup: stake::state::Lockup::default(), + }; + let reserve_stake_account = SolanaAccount::create( + stake_amount + STAKE_ACCOUNT_RENT_EXEMPTION, + bincode::serialize::(&stake::state::StakeStateV2::Initialized( + meta, + )) + .unwrap(), + stake::program::id(), + false, + Epoch::default(), + ); + program_test.add_account(*reserve_stake, reserve_stake_account); +} + +pub fn add_stake_pool_account( + program_test: &mut ProgramTest, + stake_pool_pubkey: &Pubkey, + stake_pool: &state::StakePool, +) { + let mut stake_pool_bytes = borsh::to_vec(&stake_pool).unwrap(); + // more room for optionals + stake_pool_bytes.extend_from_slice(Pubkey::default().as_ref()); + stake_pool_bytes.extend_from_slice(Pubkey::default().as_ref()); + let stake_pool_account = SolanaAccount::create( + ACCOUNT_RENT_EXEMPTION, + stake_pool_bytes, + id(), + false, + Epoch::default(), + ); + program_test.add_account(*stake_pool_pubkey, stake_pool_account); +} + +pub fn add_validator_list_account( + program_test: &mut ProgramTest, + validator_list_pubkey: &Pubkey, + validator_list: &state::ValidatorList, + max_validators: u32, +) { + let mut validator_list_bytes = borsh::to_vec(&validator_list).unwrap(); + // add extra room if needed + for _ in validator_list.validators.len()..max_validators as usize { + validator_list_bytes + .append(&mut borsh::to_vec(&state::ValidatorStakeInfo::default()).unwrap()); + } + let validator_list_account = SolanaAccount::create( + ACCOUNT_RENT_EXEMPTION, + validator_list_bytes, + id(), + false, + Epoch::default(), + ); + program_test.add_account(*validator_list_pubkey, validator_list_account); +} + +pub fn add_mint_account( + program_test: &mut ProgramTest, + program_id: &Pubkey, + mint_key: &Pubkey, + mint_authority: &Pubkey, + supply: u64, +) { + let mut mint_vec = vec![0u8; Mint::LEN]; + let mint = Mint { + mint_authority: COption::Some(*mint_authority), + supply, + decimals: 9, + is_initialized: true, + freeze_authority: COption::None, + }; + Pack::pack(mint, &mut mint_vec).unwrap(); + let stake_pool_mint = SolanaAccount::create( + ACCOUNT_RENT_EXEMPTION, + mint_vec, + *program_id, + false, + Epoch::default(), + ); + program_test.add_account(*mint_key, stake_pool_mint); +} + +pub fn add_token_account( + program_test: &mut ProgramTest, + program_id: &Pubkey, + account_key: &Pubkey, + mint_key: &Pubkey, + owner: &Pubkey, +) { + let mut fee_account_vec = vec![0u8; Account::LEN]; + let fee_account_data = Account { + mint: *mint_key, + owner: *owner, + amount: 0, + delegate: COption::None, + state: spl_token_2022::state::AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + Pack::pack(fee_account_data, &mut fee_account_vec).unwrap(); + let fee_account = SolanaAccount::create( + ACCOUNT_RENT_EXEMPTION, + fee_account_vec, + *program_id, + false, + Epoch::default(), + ); + program_test.add_account(*account_key, fee_account); +} + +pub async fn setup_for_withdraw( + token_program_id: Pubkey, + reserve_lamports: u64, +) -> ( + ProgramTestContext, + StakePoolAccounts, + ValidatorStakeAccount, + DepositStakeAccount, + Keypair, + Keypair, + u64, +) { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + reserve_lamports, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let deposit_info = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake_account, + current_minimum_delegation * 3, + ) + .await + .unwrap(); + + let tokens_to_withdraw = deposit_info.pool_tokens; + + // Delegate tokens for withdrawing + let user_transfer_authority = Keypair::new(); + delegate_tokens( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + &user_transfer_authority.pubkey(), + tokens_to_withdraw, + ) + .await; + + // Create stake account to withdraw to + let user_stake_recipient = Keypair::new(); + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient, + ) + .await; + + ( + context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum DecreaseInstruction { + Additional, + Reserve, + Deprecated, +} diff --git a/program/tests/huge_pool.rs b/program/tests/huge_pool.rs new file mode 100644 index 00000000..52662095 --- /dev/null +++ b/program/tests/huge_pool.rs @@ -0,0 +1,710 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{borsh1::try_from_slice_unchecked, pubkey::Pubkey, stake}, + solana_program_test::*, + solana_sdk::{ + native_token::LAMPORTS_PER_SOL, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + spl_stake_pool::{ + find_stake_program_address, find_transient_stake_program_address, id, + instruction::{self, PreferredValidatorType}, + state::{StakePool, StakeStatus, ValidatorList}, + MAX_VALIDATORS_TO_UPDATE, + }, + test_case::test_case, +}; + +// Note: this is not the real max! The testing framework starts to blow out +// because the test require so many helper accounts. +// 20k is also a very safe number for the current upper bound of the network. +const MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS: u32 = 20_000; +const MAX_POOL_SIZE: u32 = 3_000; +const STAKE_AMOUNT: u64 = 200_000_000_000; + +async fn setup( + max_validators: u32, + num_validators: u32, + stake_amount: u64, +) -> ( + ProgramTestContext, + StakePoolAccounts, + Vec, + Pubkey, + Keypair, + Pubkey, + Pubkey, +) { + let mut program_test = program_test(); + let mut vote_account_pubkeys = vec![]; + let mut stake_pool_accounts = StakePoolAccounts { + max_validators, + ..Default::default() + }; + if max_validators > MAX_POOL_SIZE { + stake_pool_accounts.compute_unit_limit = Some(1_400_000); + } + + let stake_pool_pubkey = stake_pool_accounts.stake_pool.pubkey(); + let (mut stake_pool, mut validator_list) = stake_pool_accounts.state(); + stake_pool.last_update_epoch = FIRST_NORMAL_EPOCH; + + for _ in 0..max_validators { + vote_account_pubkeys.push(add_vote_account(&mut program_test)); + } + + for vote_account_address in vote_account_pubkeys.iter().take(num_validators as usize) { + add_validator_stake_account( + &mut program_test, + &mut stake_pool, + &mut validator_list, + &stake_pool_pubkey, + &stake_pool_accounts.withdraw_authority, + vote_account_address, + stake_amount, + StakeStatus::Active, + ); + } + + add_reserve_stake_account( + &mut program_test, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.withdraw_authority, + stake_amount, + ); + add_stake_pool_account( + &mut program_test, + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool, + ); + add_validator_list_account( + &mut program_test, + &stake_pool_accounts.validator_list.pubkey(), + &validator_list, + max_validators, + ); + + add_mint_account( + &mut program_test, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.withdraw_authority, + stake_pool.pool_token_supply, + ); + add_token_account( + &mut program_test, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager.pubkey(), + ); + + let mut context = program_test.start_with_context().await; + let epoch_schedule = &context.genesis_config().epoch_schedule; + let slot = epoch_schedule.first_normal_slot + epoch_schedule.slots_per_epoch + 1; + context.warp_to_slot(slot).unwrap(); + + let vote_pubkey = vote_account_pubkeys[max_validators as usize - 1]; + // make stake account + let user = Keypair::new(); + let deposit_stake = Keypair::new(); + let lockup = stake::state::Lockup::default(); + + let authorized = stake::state::Authorized { + staker: user.pubkey(), + withdrawer: user.pubkey(), + }; + + let _stake_lamports = create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake, + &authorized, + &lockup, + stake_amount, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &deposit_stake.pubkey(), + &user, + &vote_pubkey, + ) + .await; + + // make pool token account + let pool_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + vote_account_pubkeys, + vote_pubkey, + user, + deposit_stake.pubkey(), + pool_token_account.pubkey(), + ) +} + +#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn update(max_validators: u32) { + let (mut context, stake_pool_accounts, _, _, _, _, _) = + setup(max_validators, max_validators, STAKE_AMOUNT).await; + + let error = stake_pool_accounts + .update_validator_list_balance( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MAX_VALIDATORS_TO_UPDATE, + false, /* no_merge */ + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .update_stake_pool_balance( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .cleanup_removed_validator_entries( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +//#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn remove_validator_from_pool(max_validators: u32) { + let (mut context, stake_pool_accounts, vote_account_pubkeys, _, _, _, _) = + setup(max_validators, max_validators, LAMPORTS_PER_SOL).await; + + let first_vote = vote_account_pubkeys[0]; + let (stake_address, _) = find_stake_program_address( + &id(), + &first_vote, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + let transient_stake_seed = u64::MAX; + let (transient_stake_address, _) = find_transient_stake_program_address( + &id(), + &first_vote, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_address, + &transient_stake_address, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let middle_index = max_validators as usize / 2; + let middle_vote = vote_account_pubkeys[middle_index]; + let (stake_address, _) = find_stake_program_address( + &id(), + &middle_vote, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + let (transient_stake_address, _) = find_transient_stake_program_address( + &id(), + &middle_vote, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_address, + &transient_stake_address, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let last_index = max_validators as usize - 1; + let last_vote = vote_account_pubkeys[last_index]; + let (stake_address, _) = find_stake_program_address( + &id(), + &last_vote, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + let (transient_stake_address, _) = find_transient_stake_program_address( + &id(), + &last_vote, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_address, + &transient_stake_address, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let first_element = &validator_list.validators[0]; + assert_eq!( + first_element.status, + StakeStatus::DeactivatingValidator.into() + ); + assert_eq!( + u64::from(first_element.active_stake_lamports), + LAMPORTS_PER_SOL + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!(u64::from(first_element.transient_stake_lamports), 0); + + let middle_element = &validator_list.validators[middle_index]; + assert_eq!( + middle_element.status, + StakeStatus::DeactivatingValidator.into() + ); + assert_eq!( + u64::from(middle_element.active_stake_lamports), + LAMPORTS_PER_SOL + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!(u64::from(middle_element.transient_stake_lamports), 0); + + let last_element = &validator_list.validators[last_index]; + assert_eq!( + last_element.status, + StakeStatus::DeactivatingValidator.into() + ); + assert_eq!( + u64::from(last_element.active_stake_lamports), + LAMPORTS_PER_SOL + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!(u64::from(last_element.transient_stake_lamports), 0); + + let error = stake_pool_accounts + .update_validator_list_balance( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + 1, + false, /* no_merge */ + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let mut instructions = vec![instruction::update_validator_list_balance_chunk( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_list, + 1, + middle_index, + /* no_merge = */ false, + ) + .unwrap()]; + stake_pool_accounts.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err(); + assert!(error.is_none(), "{:?}", error); + + let mut instructions = vec![instruction::update_validator_list_balance_chunk( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_list, + 1, + last_index, + /* no_merge = */ false, + ) + .unwrap()]; + stake_pool_accounts.maybe_add_compute_budget_instruction(&mut instructions); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err(); + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .cleanup_removed_validator_entries( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!(validator_list.validators.len() as u32, max_validators - 3); + // assert they're gone + assert!(!validator_list + .validators + .iter() + .any(|x| x.vote_account_address == first_vote)); + assert!(!validator_list + .validators + .iter() + .any(|x| x.vote_account_address == middle_vote)); + assert!(!validator_list + .validators + .iter() + .any(|x| x.vote_account_address == last_vote)); + + // but that we didn't remove too many + assert!(validator_list + .validators + .iter() + .any(|x| x.vote_account_address == vote_account_pubkeys[1])); + assert!(validator_list + .validators + .iter() + .any(|x| x.vote_account_address == vote_account_pubkeys[middle_index - 1])); + assert!(validator_list + .validators + .iter() + .any(|x| x.vote_account_address == vote_account_pubkeys[middle_index + 1])); + assert!(validator_list + .validators + .iter() + .any(|x| x.vote_account_address == vote_account_pubkeys[last_index - 1])); +} + +//#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn add_validator_to_pool(max_validators: u32) { + let (mut context, stake_pool_accounts, _, test_vote_address, _, _, _) = + setup(max_validators, max_validators - 1, STAKE_AMOUNT).await; + + let last_index = max_validators as usize - 1; + let stake_pool_pubkey = stake_pool_accounts.stake_pool.pubkey(); + let (stake_address, _) = + find_stake_program_address(&id(), &test_vote_address, &stake_pool_pubkey, None); + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_address, + &test_vote_address, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!(validator_list.validators.len(), last_index + 1); + let last_element = validator_list.validators[last_index]; + assert_eq!(last_element.status, StakeStatus::Active.into()); + assert_eq!( + u64::from(last_element.active_stake_lamports), + LAMPORTS_PER_SOL + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!(u64::from(last_element.transient_stake_lamports), 0); + assert_eq!(last_element.vote_account_address, test_vote_address); + + let transient_stake_seed = u64::MAX; + let (transient_stake_address, _) = find_transient_stake_program_address( + &id(), + &test_vote_address, + &stake_pool_pubkey, + transient_stake_seed, + ); + let increase_amount = LAMPORTS_PER_SOL; + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &transient_stake_address, + &stake_address, + &test_vote_address, + increase_amount, + transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let last_element = validator_list.validators[last_index]; + assert_eq!(last_element.status, StakeStatus::Active.into()); + assert_eq!( + u64::from(last_element.active_stake_lamports), + LAMPORTS_PER_SOL + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!( + u64::from(last_element.transient_stake_lamports), + increase_amount + STAKE_ACCOUNT_RENT_EXEMPTION + ); + assert_eq!(last_element.vote_account_address, test_vote_address); +} + +//#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn set_preferred(max_validators: u32) { + let (mut context, stake_pool_accounts, _, vote_account_address, _, _, _) = + setup(max_validators, max_validators, STAKE_AMOUNT).await; + + let error = stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + PreferredValidatorType::Deposit, + Some(vote_account_address), + ) + .await; + assert!(error.is_none(), "{:?}", error); + let error = stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + PreferredValidatorType::Withdraw, + Some(vote_account_address), + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.preferred_deposit_validator_vote_address, + Some(vote_account_address) + ); + assert_eq!( + stake_pool.preferred_withdraw_validator_vote_address, + Some(vote_account_address) + ); +} + +#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn deposit_stake(max_validators: u32) { + let (mut context, stake_pool_accounts, _, vote_pubkey, user, stake_pubkey, pool_account_pubkey) = + setup(max_validators, max_validators, STAKE_AMOUNT).await; + + let (stake_address, _) = find_stake_program_address( + &id(), + &vote_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + + let error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pubkey, + &pool_account_pubkey, + &stake_address, + &user, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn withdraw(max_validators: u32) { + let (mut context, stake_pool_accounts, _, vote_pubkey, user, stake_pubkey, pool_account_pubkey) = + setup(max_validators, max_validators, STAKE_AMOUNT).await; + + let (stake_address, _) = find_stake_program_address( + &id(), + &vote_pubkey, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + + let error = stake_pool_accounts + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pubkey, + &pool_account_pubkey, + &stake_address, + &user, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Create stake account to withdraw to + let user_stake_recipient = Keypair::new(); + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient, + ) + .await; + + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user, + &pool_account_pubkey, + &stake_address, + &user.pubkey(), + TEST_STAKE_AMOUNT, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +//#[test_case(MAX_POOL_SIZE_WITH_REQUESTED_COMPUTE_UNITS; "compute-budget")] +#[test_case(MAX_POOL_SIZE; "no-compute-budget")] +#[tokio::test] +async fn cleanup_all(max_validators: u32) { + let mut program_test = program_test(); + let mut vote_account_pubkeys = vec![]; + let mut stake_pool_accounts = StakePoolAccounts { + max_validators, + ..Default::default() + }; + if max_validators > MAX_POOL_SIZE { + stake_pool_accounts.compute_unit_limit = Some(1_400_000); + } + + let stake_pool_pubkey = stake_pool_accounts.stake_pool.pubkey(); + let (mut stake_pool, mut validator_list) = stake_pool_accounts.state(); + + for _ in 0..max_validators { + vote_account_pubkeys.push(add_vote_account(&mut program_test)); + } + + for vote_account_address in vote_account_pubkeys.iter() { + add_validator_stake_account( + &mut program_test, + &mut stake_pool, + &mut validator_list, + &stake_pool_pubkey, + &stake_pool_accounts.withdraw_authority, + vote_account_address, + STAKE_AMOUNT, + StakeStatus::ReadyForRemoval, + ); + } + + add_stake_pool_account( + &mut program_test, + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool, + ); + add_validator_list_account( + &mut program_test, + &stake_pool_accounts.validator_list.pubkey(), + &validator_list, + max_validators, + ); + let mut context = program_test.start_with_context().await; + + let error = stake_pool_accounts + .cleanup_removed_validator_entries( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} diff --git a/program/tests/increase.rs b/program/tests/increase.rs new file mode 100644 index 00000000..0d6c4bda --- /dev/null +++ b/program/tests/increase.rs @@ -0,0 +1,595 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + bincode::deserialize, + helpers::*, + solana_program::{clock::Epoch, instruction::InstructionError, pubkey::Pubkey, stake}, + solana_program_test::*, + solana_sdk::{ + signature::Signer, + stake::instruction::StakeError, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError, find_ephemeral_stake_program_address, + find_transient_stake_program_address, id, instruction, MINIMUM_RESERVE_LAMPORTS, + }, + test_case::test_case, +}; + +async fn setup() -> ( + ProgramTestContext, + StakePoolAccounts, + ValidatorStakeAccount, + u64, +) { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + let reserve_lamports = 100_000_000_000 + MINIMUM_RESERVE_LAMPORTS; + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + reserve_lamports, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let _deposit_info = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake_account, + current_minimum_delegation * 2 + stake_rent, + ) + .await + .unwrap(); + + ( + context, + stake_pool_accounts, + validator_stake_account, + reserve_lamports, + ) +} + +#[test_case(true; "additional")] +#[test_case(false; "non-additional")] +#[tokio::test] +async fn success(use_additional_instruction: bool) { + let (mut context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + // Save reserve stake + let pre_reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + + // Check no transient stake + let transient_account = context + .banks_client + .get_account(validator_stake.transient_stake_account) + .await + .unwrap(); + assert!(transient_account.is_none()); + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let increase_amount = reserve_lamports - stake_rent - MINIMUM_RESERVE_LAMPORTS; + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + increase_amount, + validator_stake.transient_stake_seed, + use_additional_instruction, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check reserve stake account balance + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + let reserve_stake_state = + deserialize::(&reserve_stake_account.data).unwrap(); + assert_eq!( + pre_reserve_stake_account.lamports - increase_amount - stake_rent, + reserve_stake_account.lamports + ); + assert!(reserve_stake_state.delegation().is_none()); + + // Check transient stake account state and balance + let transient_stake_account = get_account( + &mut context.banks_client, + &validator_stake.transient_stake_account, + ) + .await; + let transient_stake_state = + deserialize::(&transient_stake_account.data).unwrap(); + assert_eq!( + transient_stake_account.lamports, + increase_amount + stake_rent + ); + assert_ne!( + transient_stake_state.delegation().unwrap().activation_epoch, + Epoch::MAX + ); +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let (context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let wrong_authority = Pubkey::new_unique(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::increase_validator_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &wrong_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + reserve_lamports / 2, + validator_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error"), + } +} + +#[tokio::test] +async fn fail_with_wrong_validator_list() { + let (context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let wrong_validator_list = Pubkey::new_unique(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::increase_validator_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &wrong_validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + reserve_lamports / 2, + validator_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error"), + } +} + +#[tokio::test] +async fn fail_with_unknown_validator() { + let (mut context, stake_pool_accounts, _validator_stake, reserve_lamports) = setup().await; + + let unknown_stake = create_unknown_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::increase_validator_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &unknown_stake.transient_stake_account, + &unknown_stake.stake_account, + &unknown_stake.vote.pubkey(), + reserve_lamports / 2, + unknown_stake.transient_stake_seed, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::ValidatorNotFound as u32) + ) + ); +} + +#[test_case(true; "additional")] +#[test_case(false; "non-additional")] +#[tokio::test] +async fn fail_twice_diff_seed(use_additional_instruction: bool) { + let (mut context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let first_increase = reserve_lamports / 3; + let second_increase = reserve_lamports / 4; + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + first_increase, + validator_stake.transient_stake_seed, + use_additional_instruction, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let transient_stake_seed = validator_stake.transient_stake_seed * 100; + let transient_stake_address = find_transient_stake_program_address( + &id(), + &validator_stake.vote.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ) + .0; + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &transient_stake_address, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + second_increase, + transient_stake_seed, + use_additional_instruction, + ) + .await + .unwrap() + .unwrap(); + + if use_additional_instruction { + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::InvalidSeeds) + ); + } else { + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::TransientAccountInUse as u32) + ) + ); + } +} + +#[test_case(true, true, true; "success-all-additional")] +#[test_case(true, false, true; "success-with-additional")] +#[test_case(false, true, false; "fail-without-additional")] +#[test_case(false, false, false; "fail-no-additional")] +#[tokio::test] +async fn twice(success: bool, use_additional_first_time: bool, use_additional_second_time: bool) { + let (mut context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let pre_reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + + let first_increase = reserve_lamports / 3; + let second_increase = reserve_lamports / 4; + let total_increase = first_increase + second_increase; + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + first_increase, + validator_stake.transient_stake_seed, + use_additional_first_time, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + second_increase, + validator_stake.transient_stake_seed, + use_additional_second_time, + ) + .await; + + if success { + assert!(error.is_none(), "{:?}", error); + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + // no ephemeral account + let ephemeral_stake = find_ephemeral_stake_program_address( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .0; + let ephemeral_account = context + .banks_client + .get_account(ephemeral_stake) + .await + .unwrap(); + assert!(ephemeral_account.is_none()); + // Check reserve stake account balance + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + let reserve_stake_state = + deserialize::(&reserve_stake_account.data).unwrap(); + assert_eq!( + pre_reserve_stake_account.lamports - total_increase - stake_rent * 2, + reserve_stake_account.lamports + ); + assert!(reserve_stake_state.delegation().is_none()); + + // Check transient stake account state and balance + let transient_stake_account = get_account( + &mut context.banks_client, + &validator_stake.transient_stake_account, + ) + .await; + let transient_stake_state = + deserialize::(&transient_stake_account.data).unwrap(); + assert_eq!( + transient_stake_account.lamports, + total_increase + stake_rent * 2 + ); + assert_ne!( + transient_stake_state.delegation().unwrap().activation_epoch, + Epoch::MAX + ); + + // marked correctly in the list + let validator_list = stake_pool_accounts + .get_validator_list(&mut context.banks_client) + .await; + let entry = validator_list.find(&validator_stake.vote.pubkey()).unwrap(); + assert_eq!( + u64::from(entry.transient_stake_lamports), + total_increase + stake_rent * 2 + ); + } else { + let error = error.unwrap().unwrap(); + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = StakePoolError::TransientAccountInUse as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error"), + } + } +} + +#[test_case(true; "additional")] +#[test_case(false; "non-additional")] +#[tokio::test] +async fn fail_with_small_lamport_amount(use_additional_instruction: bool) { + let (mut context, stake_pool_accounts, validator_stake, _reserve_lamports) = setup().await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + current_minimum_delegation - 1, + validator_stake.transient_stake_seed, + use_additional_instruction, + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = StakeError::InsufficientDelegation as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error"), + } +} + +#[test_case(true; "additional")] +#[test_case(false; "non-additional")] +#[tokio::test] +async fn fail_overdraw_reserve(use_additional_instruction: bool) { + let (mut context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + reserve_lamports, + validator_stake.transient_stake_seed, + use_additional_instruction, + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::InsufficientFunds) => {} + _ => panic!("Wrong error occurs while overdrawing reserve stake"), + } +} + +#[tokio::test] +async fn fail_additional_with_decreasing() { + let (mut context, stake_pool_accounts, validator_stake, reserve_lamports) = setup().await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // warp forward to activation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + current_minimum_delegation + stake_rent, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .increase_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + reserve_lamports / 2, + validator_stake.transient_stake_seed, + true, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::WrongStakeStake as u32) + ) + ); +} + +#[tokio::test] +async fn fail_with_force_destaked_validator() {} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs new file mode 100644 index 00000000..fa65c709 --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,1632 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::{get_instance_packed_len, get_packed_len, try_from_slice_unchecked}, + hash::Hash, + instruction::{AccountMeta, Instruction}, + program_pack::Pack, + pubkey::Pubkey, + stake, system_instruction, sysvar, + }, + solana_program_test::*, + solana_sdk::{ + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{error, id, instruction, state, MINIMUM_RESERVE_LAMPORTS}, + spl_token_2022::extension::ExtensionType, + test_case::test_case, +}; + +async fn create_required_accounts( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + stake_pool_accounts: &StakePoolAccounts, + mint_extensions: &[ExtensionType], +) { + create_mint( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint, + &stake_pool_accounts.withdraw_authority, + stake_pool_accounts.pool_decimals, + mint_extensions, + ) + .await + .unwrap(); + + let required_extensions = ExtensionType::get_required_init_account_extensions(mint_extensions); + create_token_account( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &required_extensions, + ) + .await + .unwrap(); + + create_independent_stake_account( + banks_client, + payer, + recent_blockhash, + &stake_pool_accounts.reserve_stake, + &stake::state::Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + &stake::state::Lockup::default(), + MINIMUM_RESERVE_LAMPORTS, + ) + .await; +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success(token_program_id: Pubkey) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + // Stake pool now exists + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + assert_eq!(stake_pool.data.len(), get_packed_len::()); + assert_eq!(stake_pool.owner, id()); + + // Validator stake list storage initialized + let validator_list = get_account( + &mut banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert!(validator_list.header.is_valid()); +} + +#[tokio::test] +async fn fail_double_initialize() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let latest_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let second_stake_pool_accounts = StakePoolAccounts { + stake_pool: stake_pool_accounts.stake_pool, + ..Default::default() + }; + + let transaction_error = second_stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &latest_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .err() + .unwrap(); + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::AlreadyInUse as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize already initialized stake pool"), + } +} + +#[tokio::test] +async fn fail_with_already_initialized_validator_list() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let latest_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let second_stake_pool_accounts = StakePoolAccounts { + validator_list: stake_pool_accounts.validator_list, + ..Default::default() + }; + + let transaction_error = second_stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &latest_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .err() + .unwrap(); + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::AlreadyInUse as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize stake pool with already initialized stake list storage"), + } +} + +#[tokio::test] +async fn fail_with_high_fee() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts { + epoch_fee: state::Fee { + numerator: 100_001, + denominator: 100_000, + }, + ..Default::default() + }; + + let transaction_error = stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .err() + .unwrap(); + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize stake pool with high fee"), + } +} + +#[tokio::test] +async fn fail_with_high_withdrawal_fee() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts { + withdrawal_fee: state::Fee { + numerator: 100_001, + denominator: 100_000, + }, + ..Default::default() + }; + + let transaction_error = stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .err() + .unwrap(); + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => { + panic!("Wrong error occurs while try to initialize stake pool with high withdrawal fee") + } + } +} + +#[tokio::test] +async fn fail_with_wrong_max_validators() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + let rent = banks_client.get_rent().await.unwrap(); + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators - 1, + )) + .unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &spl_token::id(), + None, + stake_pool_accounts.epoch_fee, + stake_pool_accounts.withdrawal_fee, + stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + stake_pool_accounts.max_validators, + ), + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.manager, + ], + recent_blockhash, + ); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::UnexpectedValidatorListAccountSize as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize stake pool with high fee"), + } +} + +#[tokio::test] +async fn fail_with_wrong_mint_authority() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + let wrong_mint = Keypair::new(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + // create wrong mint + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &wrong_mint, + &stake_pool_accounts.withdraw_authority, + stake_pool_accounts.pool_decimals, + &[], + ) + .await + .unwrap(); + + let transaction_error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &wrong_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::InvalidFeeAccount as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize stake pool with wrong mint authority of pool fee account"), + } +} + +#[tokio::test] +async fn fail_with_freeze_authority() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + // create mint with freeze authority + let wrong_mint = Keypair::new(); + let rent = banks_client.get_rent().await.unwrap(); + let mint_rent = rent.minimum_balance(spl_token::state::Mint::LEN); + + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &wrong_mint.pubkey(), + mint_rent, + spl_token::state::Mint::LEN as u64, + &spl_token::id(), + ), + spl_token::instruction::initialize_mint( + &spl_token::id(), + &wrong_mint.pubkey(), + &stake_pool_accounts.withdraw_authority, + Some(&stake_pool_accounts.withdraw_authority), + stake_pool_accounts.pool_decimals, + ) + .unwrap(), + ], + Some(&payer.pubkey()), + &[&payer, &wrong_mint], + recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + let pool_fee_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_fee_account, + &wrong_mint.pubkey(), + &stake_pool_accounts.manager, + &[], + ) + .await + .unwrap(); + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &wrong_mint.pubkey(), + &pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::InvalidMintFreezeAuthority as u32), + ) + ); +} + +#[tokio::test] +async fn success_with_supported_extensions() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(spl_token_2022::id()); + + let mint_extensions = vec![ExtensionType::TransferFeeConfig]; + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &mint_extensions, + ) + .await; + + let mut account_extensions = + ExtensionType::get_required_init_account_extensions(&mint_extensions); + account_extensions.push(ExtensionType::CpiGuard); + let pool_fee_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &account_extensions, + ) + .await + .unwrap(); + + create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn fail_with_unsupported_mint_extension() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(spl_token_2022::id()); + + let mint_extensions = vec![ExtensionType::NonTransferable]; + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &mint_extensions, + ) + .await; + + let required_extensions = ExtensionType::get_required_init_account_extensions(&mint_extensions); + let pool_fee_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &required_extensions, + ) + .await + .unwrap(); + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::UnsupportedMintExtension as u32), + ) + ); +} + +#[tokio::test] +async fn fail_with_unsupported_account_extension() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(spl_token_2022::id()); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + let extensions = vec![ExtensionType::MemoTransfer]; + let pool_fee_account = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &extensions, + ) + .await + .unwrap(); + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::UnsupportedFeeAccountExtension as u32), + ) + ); +} + +#[tokio::test] +async fn fail_with_wrong_token_program_id() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + let wrong_token_program = Keypair::new(); + + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint, + &stake_pool_accounts.withdraw_authority, + stake_pool_accounts.pool_decimals, + &[], + ) + .await + .unwrap(); + + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &[], + ) + .await + .unwrap(); + + let rent = banks_client.get_rent().await.unwrap(); + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators, + )) + .unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &wrong_token_program.pubkey(), + None, + stake_pool_accounts.epoch_fee, + stake_pool_accounts.withdrawal_fee, + stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + stake_pool_accounts.max_validators, + ), + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.manager, + ], + recent_blockhash, + ); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!( + "Wrong error occurs while try to initialize stake pool with wrong token program ID" + ), + } +} + +#[tokio::test] +async fn fail_with_fee_owned_by_wrong_token_program_id() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + let wrong_token_program = Keypair::new(); + + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint, + &stake_pool_accounts.withdraw_authority, + stake_pool_accounts.pool_decimals, + &[], + ) + .await + .unwrap(); + + let rent = banks_client.get_rent().await.unwrap(); + + let account_rent = rent.minimum_balance(spl_token::state::Account::LEN); + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + account_rent, + spl_token::state::Account::LEN as u64, + &wrong_token_program.pubkey(), + )], + Some(&payer.pubkey()), + &[&payer, &stake_pool_accounts.pool_fee_account], + recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators, + )) + .unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &wrong_token_program.pubkey(), + None, + stake_pool_accounts.epoch_fee, + stake_pool_accounts.withdrawal_fee, + stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + stake_pool_accounts.max_validators, + ), + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.manager, + ], + recent_blockhash, + ); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!( + "Wrong error occurs while try to initialize stake pool with wrong token program ID" + ), + } +} + +#[tokio::test] +async fn fail_with_wrong_fee_account() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint, + &stake_pool_accounts.withdraw_authority, + stake_pool_accounts.pool_decimals, + &[], + ) + .await + .unwrap(); + let rent = banks_client.get_rent().await.unwrap(); + let account_rent = rent.minimum_balance(spl_token::state::Account::LEN); + + let mut transaction = Transaction::new_with_payer( + &[system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + account_rent, + spl_token::state::Account::LEN as u64, + &Keypair::new().pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign( + &[&payer, &stake_pool_accounts.pool_fee_account], + recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + let transaction_error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + transaction_error, + TransactionError::InstructionError(2, InstructionError::UninitializedAccount) + ); +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts { + withdraw_authority: Keypair::new().pubkey(), + ..Default::default() + }; + + let transaction_error = stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .err() + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!( + "Wrong error occurs while try to initialize stake pool with wrong withdraw authority" + ), + } +} + +#[tokio::test] +async fn fail_with_not_rent_exempt_pool() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + let rent = banks_client.get_rent().await.unwrap(); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators, + )) + .unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + 1, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &spl_token::id(), + None, + stake_pool_accounts.epoch_fee, + stake_pool_accounts.withdrawal_fee, + stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + stake_pool_accounts.max_validators, + ), + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.manager, + ], + recent_blockhash, + ); + let result = banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert!( + result == TransactionError::InstructionError(2, InstructionError::InvalidError,) + || result + == TransactionError::InstructionError(2, InstructionError::AccountNotRentExempt,) + ); +} + +#[tokio::test] +async fn fail_with_not_rent_exempt_validator_list() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + let rent = banks_client.get_rent().await.unwrap(); + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators, + )) + .unwrap(); + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + 1, + validator_list_size as u64, + &id(), + ), + instruction::initialize( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &spl_token::id(), + None, + stake_pool_accounts.epoch_fee, + stake_pool_accounts.withdrawal_fee, + stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + stake_pool_accounts.max_validators, + ), + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.manager, + ], + recent_blockhash, + ); + + let result = banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + + assert!( + result == TransactionError::InstructionError(2, InstructionError::InvalidError,) + || result + == TransactionError::InstructionError(2, InstructionError::AccountNotRentExempt,) + ); +} + +#[tokio::test] +async fn fail_without_manager_signature() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + let rent = banks_client.get_rent().await.unwrap(); + let rent_stake_pool = rent.minimum_balance(get_packed_len::()); + let validator_list_size = get_instance_packed_len(&state::ValidatorList::new( + stake_pool_accounts.max_validators, + )) + .unwrap(); + let rent_validator_list = rent.minimum_balance(validator_list_size); + + let init_data = instruction::StakePoolInstruction::Initialize { + fee: stake_pool_accounts.epoch_fee, + withdrawal_fee: stake_pool_accounts.withdrawal_fee, + deposit_fee: stake_pool_accounts.deposit_fee, + referral_fee: stake_pool_accounts.referral_fee, + max_validators: stake_pool_accounts.max_validators, + }; + let data = borsh::to_vec(&init_data).unwrap(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), true), + AccountMeta::new_readonly(stake_pool_accounts.manager.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.reserve_stake.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.pool_mint.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.pool_fee_account.pubkey(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + ]; + let stake_pool_init_instruction = Instruction { + program_id: id(), + accounts, + data, + }; + + let mut transaction = Transaction::new_with_payer( + &[ + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + rent_stake_pool, + get_packed_len::() as u64, + &id(), + ), + system_instruction::create_account( + &payer.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + rent_validator_list, + validator_list_size as u64, + &id(), + ), + stake_pool_init_instruction, + ], + Some(&payer.pubkey()), + ); + transaction.sign( + &[ + &payer, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + ], + recent_blockhash, + ); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!( + "Wrong error occurs while try to initialize stake pool without manager's signature" + ), + } +} + +#[tokio::test] +async fn fail_with_pre_minted_pool_tokens() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + let mint_authority = Keypair::new(); + + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint, + &mint_authority.pubkey(), + stake_pool_accounts.pool_decimals, + &[], + ) + .await + .unwrap(); + + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + &[], + ) + .await + .unwrap(); + + mint_tokens( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &mint_authority, + 1, + ) + .await + .unwrap(); + + let transaction_error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::NonZeroPoolTokenSupply as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to initialize stake pool with wrong mint authority of pool fee account"), + } +} + +#[tokio::test] +async fn fail_with_bad_reserve() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + let wrong_authority = Pubkey::new_unique(); + + create_required_accounts( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + &[], + ) + .await; + + { + let bad_stake = Keypair::new(); + create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &bad_stake, + &stake::state::Authorized { + staker: wrong_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + &stake::state::Lockup::default(), + MINIMUM_RESERVE_LAMPORTS, + ) + .await; + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &bad_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::WrongStakeStake as u32), + ) + ); + } + + { + let bad_stake = Keypair::new(); + create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &bad_stake, + &stake::state::Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: wrong_authority, + }, + &stake::state::Lockup::default(), + MINIMUM_RESERVE_LAMPORTS, + ) + .await; + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &bad_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::WrongStakeStake as u32), + ) + ); + } + + { + let bad_stake = Keypair::new(); + create_independent_stake_account( + &mut banks_client, + &payer, + &recent_blockhash, + &bad_stake, + &stake::state::Authorized { + staker: stake_pool_accounts.withdraw_authority, + withdrawer: stake_pool_accounts.withdraw_authority, + }, + &stake::state::Lockup { + custodian: wrong_authority, + ..stake::state::Lockup::default() + }, + MINIMUM_RESERVE_LAMPORTS, + ) + .await; + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &bad_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::WrongStakeStake as u32), + ) + ); + } + + { + let bad_stake = Keypair::new(); + let rent = banks_client.get_rent().await.unwrap(); + let lamports = rent.minimum_balance(std::mem::size_of::()) + + MINIMUM_RESERVE_LAMPORTS; + + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &payer.pubkey(), + &bad_stake.pubkey(), + lamports, + std::mem::size_of::() as u64, + &stake::program::id(), + )], + Some(&payer.pubkey()), + &[&payer, &bad_stake], + recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + let error = create_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.stake_pool, + &stake_pool_accounts.validator_list, + &bad_stake.pubkey(), + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &None, + &stake_pool_accounts.epoch_fee, + &stake_pool_accounts.withdrawal_fee, + &stake_pool_accounts.deposit_fee, + stake_pool_accounts.referral_fee, + &stake_pool_accounts.sol_deposit_fee, + stake_pool_accounts.sol_referral_fee, + stake_pool_accounts.max_validators, + ) + .await + .err() + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::WrongStakeStake as u32), + ) + ); + } +} + +#[tokio::test] +async fn success_with_extra_reserve_lamports() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + let init_lamports = 1_000_000_000_000; + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS + init_lamports, + ) + .await + .unwrap(); + + let init_pool_tokens = get_token_balance( + &mut banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + assert_eq!(init_pool_tokens, init_lamports); +} + +#[tokio::test] +async fn fail_with_incorrect_mint_decimals() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts { + pool_decimals: 8, + ..Default::default() + }; + let error = stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap_err() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 2, + InstructionError::Custom(error::StakePoolError::IncorrectMintDecimals as u32), + ) + ); +} diff --git a/program/tests/set_deposit_fee.rs b/program/tests/set_deposit_fee.rs new file mode 100644 index 00000000..618228aa --- /dev/null +++ b/program/tests/set_deposit_fee.rs @@ -0,0 +1,279 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error, id, instruction, + state::{Fee, FeeType, StakePool}, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup(fee: Option) -> (ProgramTestContext, StakePoolAccounts, Fee) { + let mut context = program_test().start_with_context().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + if let Some(fee) = fee { + stake_pool_accounts.deposit_fee = fee; + } + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_deposit_fee = Fee { + numerator: 823, + denominator: 1000, + }; + + (context, stake_pool_accounts, new_deposit_fee) +} + +#[tokio::test] +async fn success_stake() { + let (mut context, stake_pool_accounts, new_deposit_fee) = setup(None).await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_deposit_fee, new_deposit_fee); +} + +#[tokio::test] +async fn success_stake_increase_fee_from_0() { + let (mut context, stake_pool_accounts, _) = setup(Some(Fee { + numerator: 0, + denominator: 0, + })) + .await; + let new_deposit_fee = Fee { + numerator: 324, + denominator: 1234, + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_deposit_fee, new_deposit_fee); +} + +#[tokio::test] +async fn fail_stake_wrong_manager() { + let (context, stake_pool_accounts, new_deposit_fee) = setup(None).await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::StakeDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_stake_high_deposit_fee() { + let (context, stake_pool_accounts, _new_deposit_fee) = setup(None).await; + + let new_deposit_fee = Fee { + numerator: 100001, + denominator: 100000, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} + +#[tokio::test] +async fn success_sol() { + let (mut context, stake_pool_accounts, new_deposit_fee) = setup(None).await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.sol_deposit_fee, new_deposit_fee); +} + +#[tokio::test] +async fn fail_sol_wrong_manager() { + let (context, stake_pool_accounts, new_deposit_fee) = setup(None).await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::SolDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_sol_high_deposit_fee() { + let (context, stake_pool_accounts, _new_deposit_fee) = setup(None).await; + + let new_deposit_fee = Fee { + numerator: 100001, + denominator: 100000, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolDeposit(new_deposit_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} diff --git a/program/tests/set_epoch_fee.rs b/program/tests/set_epoch_fee.rs new file mode 100644 index 00000000..e7bb6cd5 --- /dev/null +++ b/program/tests/set_epoch_fee.rs @@ -0,0 +1,245 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error, id, instruction, + state::{Fee, FeeType, FutureEpoch, StakePool}, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup() -> (ProgramTestContext, StakePoolAccounts, Fee) { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_fee = Fee { + numerator: 10, + denominator: 10, + }; + + (context, stake_pool_accounts, new_fee) +} + +#[tokio::test] +async fn success() { + let (mut context, stake_pool_accounts, new_fee) = setup().await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let old_fee = stake_pool.epoch_fee; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::Epoch(new_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.epoch_fee, old_fee); + assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::Two(new_fee)); + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let slot = first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.epoch_fee, old_fee); + assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::One(new_fee)); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + context.warp_to_slot(slot + slots_per_epoch).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.epoch_fee, new_fee); + assert_eq!(stake_pool.next_epoch_fee, FutureEpoch::None); +} + +#[tokio::test] +async fn fail_wrong_manager() { + let (context, stake_pool_accounts, new_fee) = setup().await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::Epoch(new_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn fail_high_fee() { + let (context, stake_pool_accounts, _new_fee) = setup().await; + + let new_fee = Fee { + numerator: 11, + denominator: 10, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::Epoch(new_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} + +#[tokio::test] +async fn fail_not_updated() { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_fee = Fee { + numerator: 10, + denominator: 100, + }; + + // move forward so an update is required + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::Epoch(new_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::StakeListAndPoolOutOfDate as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when stake pool out of date"), + } +} diff --git a/program/tests/set_funding_authority.rs b/program/tests/set_funding_authority.rs new file mode 100644 index 00000000..a3cdb619 --- /dev/null +++ b/program/tests/set_funding_authority.rs @@ -0,0 +1,264 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + hash::Hash, + instruction::{AccountMeta, Instruction}, + }, + solana_program_test::*, + solana_sdk::{ + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{ + error, find_deposit_authority_program_address, id, + instruction::{self, FundingType}, + state, MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup() -> (BanksClient, Keypair, Hash, StakePoolAccounts, Keypair) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let new_deposit_authority = Keypair::new(); + + ( + banks_client, + payer, + recent_blockhash, + stake_pool_accounts, + new_deposit_authority, + ) +} + +#[tokio::test] +async fn success_set_stake_deposit_authority() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_authority) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&new_authority.pubkey()), + FundingType::StakeDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_deposit_authority, new_authority.pubkey()); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + None, + FundingType::StakeDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.stake_deposit_authority, + find_deposit_authority_program_address(&id(), &stake_pool_accounts.stake_pool.pubkey()).0 + ); +} + +#[tokio::test] +async fn fail_wrong_manager() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_authority) = setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &new_authority.pubkey(), + Some(&new_authority.pubkey()), + FundingType::StakeDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &new_authority], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn fail_without_signature() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_authority) = setup().await; + + let data = borsh::to_vec(&instruction::StakePoolInstruction::SetFundingAuthority( + FundingType::StakeDeposit, + )) + .unwrap(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.manager.pubkey(), false), + AccountMeta::new_readonly(new_authority.pubkey(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data, + }; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to set new manager without signature"), + } +} + +#[tokio::test] +async fn success_set_sol_deposit_authority() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_sol_deposit_authority) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&new_sol_deposit_authority.pubkey()), + FundingType::SolDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.sol_deposit_authority, + Some(new_sol_deposit_authority.pubkey()) + ); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + None, + FundingType::SolDeposit, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.sol_deposit_authority, None); +} + +#[tokio::test] +async fn success_set_withdraw_authority() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_authority) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&new_authority.pubkey()), + FundingType::SolWithdraw, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.sol_withdraw_authority, + Some(new_authority.pubkey()) + ); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + None, + FundingType::SolWithdraw, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.sol_withdraw_authority, None); +} diff --git a/program/tests/set_manager.rs b/program/tests/set_manager.rs new file mode 100644 index 00000000..e6f7afad --- /dev/null +++ b/program/tests/set_manager.rs @@ -0,0 +1,288 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + hash::Hash, + instruction::{AccountMeta, Instruction}, + }, + solana_program_test::*, + solana_sdk::{ + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{error, id, instruction, state, MINIMUM_RESERVE_LAMPORTS}, +}; + +async fn setup() -> ( + BanksClient, + Keypair, + Hash, + StakePoolAccounts, + Keypair, + Keypair, +) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let new_pool_fee = Keypair::new(); + let new_manager = Keypair::new(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &new_pool_fee, + &stake_pool_accounts.pool_mint.pubkey(), + &new_manager, + &[], + ) + .await + .unwrap(); + + ( + banks_client, + payer, + recent_blockhash, + stake_pool_accounts, + new_pool_fee, + new_manager, + ) +} + +#[tokio::test] +async fn test_set_manager() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_pool_fee, new_manager) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_manager( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &new_manager.pubkey(), + &new_pool_fee.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign( + &[&payer, &stake_pool_accounts.manager, &new_manager], + recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.manager, new_manager.pubkey()); +} + +#[tokio::test] +async fn test_set_manager_by_malicious() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_pool_fee, new_manager) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_manager( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &new_manager.pubkey(), + &new_manager.pubkey(), + &new_pool_fee.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &new_manager], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn test_set_manager_without_existing_signature() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_pool_fee, new_manager) = + setup().await; + + let data = borsh::to_vec(&instruction::StakePoolInstruction::SetManager).unwrap(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.manager.pubkey(), false), + AccountMeta::new_readonly(new_manager.pubkey(), true), + AccountMeta::new_readonly(new_pool_fee.pubkey(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data, + }; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer, &new_manager], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!( + "Wrong error occurs while try to set new manager without existing manager signature" + ), + } +} + +#[tokio::test] +async fn test_set_manager_without_new_signature() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_pool_fee, new_manager) = + setup().await; + + let data = borsh::to_vec(&instruction::StakePoolInstruction::SetManager).unwrap(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.manager.pubkey(), true), + AccountMeta::new_readonly(new_manager.pubkey(), false), + AccountMeta::new_readonly(new_pool_fee.pubkey(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data, + }; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => { + panic!("Wrong error occurs while try to set new manager without new manager signature") + } + } +} + +#[tokio::test] +async fn test_set_manager_with_wrong_mint_for_pool_fee_acc() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let new_mint = Keypair::new(); + let new_withdraw_auth = Keypair::new(); + let new_pool_fee = Keypair::new(); + let new_manager = Keypair::new(); + + create_mint( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &new_mint, + &new_withdraw_auth.pubkey(), + 0, + &[], + ) + .await + .unwrap(); + create_token_account( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts.token_program_id, + &new_pool_fee, + &new_mint.pubkey(), + &new_manager, + &[], + ) + .await + .unwrap(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_manager( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &new_manager.pubkey(), + &new_pool_fee.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign( + &[&payer, &stake_pool_accounts.manager, &new_manager], + recent_blockhash, + ); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::InvalidFeeAccount as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to set new manager with wrong mint"), + } +} diff --git a/program/tests/set_preferred.rs b/program/tests/set_preferred.rs new file mode 100644 index 00000000..c18fd3df --- /dev/null +++ b/program/tests/set_preferred.rs @@ -0,0 +1,263 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::hash::Hash, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error, find_transient_stake_program_address, id, + instruction::{self, PreferredValidatorType}, + state::StakePool, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup() -> ( + BanksClient, + Keypair, + Hash, + StakePoolAccounts, + ValidatorStakeAccount, +) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake_account = simple_add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + ( + banks_client, + payer, + recent_blockhash, + stake_pool_accounts, + validator_stake_account, + ) +} + +#[tokio::test] +async fn success_deposit() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake_account) = + setup().await; + + let vote_account_address = validator_stake_account.vote.pubkey(); + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Deposit, + Some(vote_account_address), + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.preferred_deposit_validator_vote_address, + Some(vote_account_address) + ); + assert_eq!(stake_pool.preferred_withdraw_validator_vote_address, None); +} + +#[tokio::test] +async fn success_withdraw() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake_account) = + setup().await; + + let vote_account_address = validator_stake_account.vote.pubkey(); + + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Withdraw, + Some(vote_account_address), + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.preferred_deposit_validator_vote_address, None); + assert_eq!( + stake_pool.preferred_withdraw_validator_vote_address, + Some(vote_account_address) + ); +} + +#[tokio::test] +async fn success_unset() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake_account) = + setup().await; + + let vote_account_address = validator_stake_account.vote.pubkey(); + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Withdraw, + Some(vote_account_address), + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!( + stake_pool.preferred_withdraw_validator_vote_address, + Some(vote_account_address) + ); + + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Withdraw, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.preferred_withdraw_validator_vote_address, None); +} + +#[tokio::test] +async fn fail_wrong_staker() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, _) = setup().await; + + let wrong_staker = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_preferred_validator( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_staker.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + PreferredValidatorType::Withdraw, + None, + )], + Some(&payer.pubkey()), + &[&payer, &wrong_staker], + recent_blockhash, + ); + let error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongStaker as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn fail_not_present_validator() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, _) = setup().await; + + let validator_vote_address = Pubkey::new_unique(); + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Withdraw, + Some(validator_vote_address), + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::ValidatorNotFound as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn fail_ready_for_removal() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake_account) = + setup().await; + let validator_vote_address = validator_stake_account.vote.pubkey(); + + // Mark validator as ready for removal + let transient_stake_seed = 0; + let (transient_stake_address, _) = find_transient_stake_program_address( + &id(), + &validator_vote_address, + &stake_pool_accounts.stake_pool.pubkey(), + transient_stake_seed, + ); + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake_account.stake_account, + &transient_stake_address, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .set_preferred_validator( + &mut banks_client, + &payer, + &recent_blockhash, + PreferredValidatorType::Withdraw, + Some(validator_vote_address), + ) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::InvalidPreferredValidator as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while trying to set ReadyForRemoval validator"), + } +} diff --git a/program/tests/set_referral_fee.rs b/program/tests/set_referral_fee.rs new file mode 100644 index 00000000..19090820 --- /dev/null +++ b/program/tests/set_referral_fee.rs @@ -0,0 +1,263 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error, id, instruction, + state::{FeeType, StakePool}, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup(fee: Option) -> (ProgramTestContext, StakePoolAccounts, u8) { + let mut context = program_test().start_with_context().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + if let Some(fee) = fee { + stake_pool_accounts.referral_fee = fee; + } + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_referral_fee = 15u8; + + (context, stake_pool_accounts, new_referral_fee) +} + +#[tokio::test] +async fn success_stake() { + let (mut context, stake_pool_accounts, new_referral_fee) = setup(None).await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_referral_fee, new_referral_fee); +} + +#[tokio::test] +async fn success_stake_increase_fee_from_0() { + let (mut context, stake_pool_accounts, _) = setup(Some(0u8)).await; + let new_referral_fee = 30u8; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_referral_fee, new_referral_fee); +} + +#[tokio::test] +async fn fail_stake_wrong_manager() { + let (context, stake_pool_accounts, new_referral_fee) = setup(None).await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::StakeReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_stake_high_referral_fee() { + let (context, stake_pool_accounts, _new_referral_fee) = setup(None).await; + + let new_referral_fee = 110u8; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} + +#[tokio::test] +async fn success_sol() { + let (mut context, stake_pool_accounts, new_referral_fee) = setup(None).await; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.sol_referral_fee, new_referral_fee); +} + +#[tokio::test] +async fn fail_sol_wrong_manager() { + let (context, stake_pool_accounts, new_referral_fee) = setup(None).await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::SolReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_sol_high_referral_fee() { + let (context, stake_pool_accounts, _new_referral_fee) = setup(None).await; + + let new_referral_fee = 110u8; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolReferral(new_referral_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} diff --git a/program/tests/set_staker.rs b/program/tests/set_staker.rs new file mode 100644 index 00000000..d3dd61b4 --- /dev/null +++ b/program/tests/set_staker.rs @@ -0,0 +1,181 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + hash::Hash, + instruction::{AccountMeta, Instruction}, + }, + solana_program_test::*, + solana_sdk::{ + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{error, id, instruction, state, MINIMUM_RESERVE_LAMPORTS}, +}; + +async fn setup() -> (BanksClient, Keypair, Hash, StakePoolAccounts, Keypair) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let new_staker = Keypair::new(); + + ( + banks_client, + payer, + recent_blockhash, + stake_pool_accounts, + new_staker, + ) +} + +#[tokio::test] +async fn success_set_staker_as_manager() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_staker) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_staker( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &new_staker.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.manager], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.staker, new_staker.pubkey()); +} + +#[tokio::test] +async fn success_set_staker_as_staker() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, new_staker) = + setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_staker( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &new_staker.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.staker], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.staker, new_staker.pubkey()); + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_staker( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &new_staker.pubkey(), + &stake_pool_accounts.staker.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &new_staker], recent_blockhash); + banks_client.process_transaction(transaction).await.unwrap(); + + let stake_pool = get_account(&mut banks_client, &stake_pool_accounts.stake_pool.pubkey()).await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.staker, stake_pool_accounts.staker.pubkey()); +} + +#[tokio::test] +async fn fail_wrong_manager() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_staker) = setup().await; + + let mut transaction = Transaction::new_with_payer( + &[instruction::set_staker( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &new_staker.pubkey(), + &new_staker.pubkey(), + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &new_staker], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to set manager"), + } +} + +#[tokio::test] +async fn fail_set_staker_without_signature() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, new_staker) = setup().await; + + let data = borsh::to_vec(&instruction::StakePoolInstruction::SetStaker).unwrap(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.manager.pubkey(), false), + AccountMeta::new_readonly(new_staker.pubkey(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data, + }; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = error::StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to set new manager without signature"), + } +} diff --git a/program/tests/set_withdrawal_fee.rs b/program/tests/set_withdrawal_fee.rs new file mode 100644 index 00000000..4725685d --- /dev/null +++ b/program/tests/set_withdrawal_fee.rs @@ -0,0 +1,913 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error, id, instruction, + state::{Fee, FeeType, FutureEpoch, StakePool}, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup(fee: Option) -> (ProgramTestContext, StakePoolAccounts, Fee) { + let mut context = program_test().start_with_context().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + if let Some(fee) = fee { + stake_pool_accounts.withdrawal_fee = fee; + } + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_withdrawal_fee = Fee { + numerator: 4, + denominator: 1000, + }; + + (context, stake_pool_accounts, new_withdrawal_fee) +} + +#[tokio::test] +async fn success() { + let (mut context, stake_pool_accounts, new_withdrawal_fee) = setup(None).await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let old_stake_withdrawal_fee = stake_pool.stake_withdrawal_fee; + let old_sol_withdrawal_fee = stake_pool.sol_withdrawal_fee; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let slot = first_normal_slot + 1; + + context.warp_to_slot(slot).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + context.warp_to_slot(slot + slots_per_epoch).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_stake_withdrawal_fee, FutureEpoch::None); + assert_eq!(stake_pool.sol_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_sol_withdrawal_fee, FutureEpoch::None); +} + +#[tokio::test] +async fn success_fee_cannot_increase_more_than_once() { + let (mut context, stake_pool_accounts, new_withdrawal_fee) = setup(None).await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let old_stake_withdrawal_fee = stake_pool.stake_withdrawal_fee; + let old_sol_withdrawal_fee = stake_pool.sol_withdrawal_fee; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let slot = first_normal_slot + 1; + + context.warp_to_slot(slot).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + context.warp_to_slot(slot + slots_per_epoch).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_stake_withdrawal_fee, FutureEpoch::None); + assert_eq!(stake_pool.sol_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_sol_withdrawal_fee, FutureEpoch::None); + + // try setting to the old fee in the same epoch + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(old_stake_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(old_sol_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, new_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(old_stake_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, new_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(old_sol_withdrawal_fee) + ); + + let error = stake_pool_accounts + .update_stake_pool_balance( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check that nothing has changed after updating the stake pool + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, new_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(old_stake_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, new_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(old_sol_withdrawal_fee) + ); +} + +#[tokio::test] +async fn success_reset_fee_after_one_epoch() { + let (mut context, stake_pool_accounts, new_withdrawal_fee) = setup(None).await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let old_stake_withdrawal_fee = stake_pool.stake_withdrawal_fee; + let old_sol_withdrawal_fee = stake_pool.sol_withdrawal_fee; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + + // Flip the two fees, resets the counter to two future epochs + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(old_sol_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(old_stake_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(old_sol_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(old_stake_withdrawal_fee) + ); +} + +#[tokio::test] +async fn success_increase_fee_from_0() { + let (mut context, stake_pool_accounts, _) = setup(Some(Fee { + numerator: 0, + denominator: 1, + })) + .await; + let new_withdrawal_fee = Fee { + numerator: 15, + denominator: 10000, + }; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + let old_stake_withdrawal_fee = stake_pool.stake_withdrawal_fee; + let old_sol_withdrawal_fee = stake_pool.sol_withdrawal_fee; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::Two(new_withdrawal_fee) + ); + + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let slot = first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, old_stake_withdrawal_fee); + assert_eq!( + stake_pool.next_stake_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + assert_eq!(stake_pool.sol_withdrawal_fee, old_sol_withdrawal_fee); + assert_eq!( + stake_pool.next_sol_withdrawal_fee, + FutureEpoch::One(new_withdrawal_fee) + ); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + context.warp_to_slot(slot + slots_per_epoch).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(stake_pool.stake_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_stake_withdrawal_fee, FutureEpoch::None); + assert_eq!(stake_pool.sol_withdrawal_fee, new_withdrawal_fee); + assert_eq!(stake_pool.next_sol_withdrawal_fee, FutureEpoch::None); +} + +#[tokio::test] +async fn fail_wrong_manager() { + let (context, stake_pool_accounts, new_stake_withdrawal_fee) = setup(None).await; + + let wrong_manager = Keypair::new(); + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &wrong_manager.pubkey(), + FeeType::StakeWithdrawal(new_stake_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &wrong_manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} + +#[tokio::test] +async fn fail_high_withdrawal_fee() { + let (context, stake_pool_accounts, _new_stake_withdrawal_fee) = setup(None).await; + + let new_stake_withdrawal_fee = Fee { + numerator: 11, + denominator: 10, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_stake_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when setting fee too high"), + } +} + +#[tokio::test] +async fn fail_high_stake_fee_increase() { + let (context, stake_pool_accounts, _new_stake_withdrawal_fee) = setup(None).await; + let new_withdrawal_fee = Fee { + numerator: 46, + denominator: 10_000, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeIncreaseTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when increasing fee by too large a factor"), + } +} + +#[tokio::test] +async fn fail_high_sol_fee_increase() { + let (context, stake_pool_accounts, _new_stake_withdrawal_fee) = setup(None).await; + let new_withdrawal_fee = Fee { + numerator: 46, + denominator: 10_000, + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeIncreaseTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when increasing fee by too large a factor"), + } +} + +#[tokio::test] +async fn fail_high_stake_fee_increase_from_0() { + let (context, stake_pool_accounts, _new_stake_withdrawal_fee) = setup(Some(Fee { + numerator: 0, + denominator: 1, + })) + .await; + let new_withdrawal_fee = Fee { + numerator: 16, + denominator: 10_000, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeIncreaseTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when increasing fee by too large a factor"), + } +} + +#[tokio::test] +async fn fail_high_sol_fee_increase_from_0() { + let (context, stake_pool_accounts, _new_stake_withdrawal_fee) = setup(Some(Fee { + numerator: 0, + denominator: 1, + })) + .await; + let new_withdrawal_fee = Fee { + numerator: 16, + denominator: 10_000, + }; + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::SolWithdrawal(new_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::FeeIncreaseTooHigh as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when increasing fee by too large a factor"), + } +} + +#[tokio::test] +async fn fail_not_updated() { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + let new_stake_withdrawal_fee = Fee { + numerator: 11, + denominator: 100, + }; + + // move forward so an update is required + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slot = first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_fee( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + FeeType::StakeWithdrawal(new_stake_withdrawal_fee), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = error::StakePoolError::StakeListAndPoolOutOfDate as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs when stake pool out of date"), + } +} diff --git a/program/tests/update_pool_token_metadata.rs b/program/tests/update_pool_token_metadata.rs new file mode 100644 index 00000000..898366c5 --- /dev/null +++ b/program/tests/update_pool_token_metadata.rs @@ -0,0 +1,191 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] +mod helpers; + +use { + helpers::*, + solana_program::instruction::InstructionError, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError::{SignatureMissing, WrongManager}, + instruction, MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup() -> (ProgramTestContext, StakePoolAccounts) { + let mut context = program_test_with_metadata_program() + .start_with_context() + .await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let name = "test_name"; + let symbol = "SYM"; + let uri = "test_uri"; + + let ix = instruction::create_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &context.payer.pubkey(), + name.to_string(), + symbol.to_string(), + uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + (context, stake_pool_accounts) +} + +#[tokio::test] +async fn success_update_pool_token_metadata() { + let (mut context, stake_pool_accounts) = setup().await; + + let updated_name = "updated_name"; + let updated_symbol = "USYM"; + let updated_uri = "updated_uri"; + + let ix = instruction::update_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + updated_name.to_string(), + updated_symbol.to_string(), + updated_uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let metadata = get_metadata_account( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + + assert!(metadata.name.starts_with(updated_name)); + assert!(metadata.symbol.starts_with(updated_symbol)); + assert!(metadata.uri.starts_with(updated_uri)); +} + +#[tokio::test] +async fn fail_manager_did_not_sign() { + let (context, stake_pool_accounts) = setup().await; + + let updated_name = "updated_name"; + let updated_symbol = "USYM"; + let updated_uri = "updated_uri"; + + let mut ix = instruction::update_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + updated_name.to_string(), + updated_symbol.to_string(), + updated_uri.to_string(), + ); + ix.accounts[1].is_signer = false; + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while manager signature missing"), + } +} + +#[tokio::test] +async fn fail_wrong_manager_signed() { + let (context, stake_pool_accounts) = setup().await; + + let updated_name = "updated_name"; + let updated_symbol = "USYM"; + let updated_uri = "updated_uri"; + + let random_keypair = Keypair::new(); + let ix = instruction::update_token_metadata( + &spl_stake_pool::id(), + &stake_pool_accounts.stake_pool.pubkey(), + &random_keypair.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + updated_name.to_string(), + updated_symbol.to_string(), + updated_uri.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[ix], + Some(&context.payer.pubkey()), + &[&context.payer, &random_keypair], + context.last_blockhash, + ); + + let error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError(_, InstructionError::Custom(error_index)) => { + let program_error = WrongManager as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while signing with the wrong manager"), + } +} diff --git a/program/tests/update_stake_pool_balance.rs b/program/tests/update_stake_pool_balance.rs new file mode 100644 index 00000000..f242e143 --- /dev/null +++ b/program/tests/update_stake_pool_balance.rs @@ -0,0 +1,413 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{borsh1::try_from_slice_unchecked, instruction::InstructionError}, + solana_program_test::*, + solana_sdk::{ + hash::Hash, + signature::{Keypair, Signer}, + stake, + transaction::TransactionError, + }, + spl_stake_pool::{error::StakePoolError, state::StakePool, MINIMUM_RESERVE_LAMPORTS}, + std::num::NonZeroU32, +}; + +const NUM_VALIDATORS: u64 = 3; + +async fn setup( + num_validators: u64, +) -> ( + ProgramTestContext, + Hash, + StakePoolAccounts, + Vec, +) { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let error = stake_pool_accounts + .deposit_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.pool_fee_account.pubkey(), + (stake_rent + current_minimum_delegation) * num_validators, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let mut last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // Add several accounts + let mut stake_accounts: Vec = vec![]; + for i in 0..num_validators { + let stake_account = ValidatorStakeAccount::new( + &stake_pool_accounts.stake_pool.pubkey(), + NonZeroU32::new(i as u32), + u64::MAX, + ); + create_vote( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.validator, + &stake_account.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.vote.pubkey(), + stake_account.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let mut deposit_account = DepositStakeAccount::new_with_vote( + stake_account.vote.pubkey(), + stake_account.stake_account, + TEST_STAKE_AMOUNT, + ); + deposit_account + .create_and_delegate(&mut context.banks_client, &context.payer, &last_blockhash) + .await; + + deposit_account + .deposit_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + ) + .await; + + last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + stake_accounts.push(stake_account); + } + + (context, last_blockhash, stake_pool_accounts, stake_accounts) +} + +#[tokio::test] +async fn success() { + let (mut context, last_blockhash, stake_pool_accounts, stake_accounts) = + setup(NUM_VALIDATORS).await; + + let pre_fee = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + + let pre_balance = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(pre_balance, stake_pool.total_lamports); + + let pre_token_supply = get_token_supply( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + + // Increment vote credits to earn rewards + const VOTE_CREDITS: u64 = 1_000; + for stake_account in &stake_accounts { + context.increment_vote_account_credits(&stake_account.vote.pubkey(), VOTE_CREDITS); + } + + // Update epoch + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // Update list and pool + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check fee + let post_balance = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(post_balance, stake_pool.total_lamports); + + let post_fee = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + let pool_token_supply = get_token_supply( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + let actual_fee = post_fee - pre_fee; + assert_eq!(pool_token_supply - pre_token_supply, actual_fee); + + let expected_fee_lamports = (post_balance - pre_balance) * stake_pool.epoch_fee.numerator + / stake_pool.epoch_fee.denominator; + let actual_fee_lamports = stake_pool.calc_pool_tokens_for_deposit(actual_fee).unwrap(); + assert_eq!(actual_fee_lamports, expected_fee_lamports); + + let expected_fee = expected_fee_lamports * pool_token_supply / post_balance; + assert_eq!(expected_fee, actual_fee); + + assert_eq!(pool_token_supply, stake_pool.pool_token_supply); + assert_eq!(pre_token_supply, stake_pool.last_epoch_pool_token_supply); + assert_eq!(pre_balance, stake_pool.last_epoch_total_lamports); +} + +#[tokio::test] +async fn success_absorbing_extra_lamports() { + let (mut context, mut last_blockhash, stake_pool_accounts, stake_accounts) = + setup(NUM_VALIDATORS).await; + + let pre_balance = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + assert_eq!(pre_balance, stake_pool.total_lamports); + + let pre_token_supply = get_token_supply( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + + // Transfer extra funds, will be absorbed during update + const EXTRA_STAKE_AMOUNT: u64 = 1_000_000; + for stake_account in &stake_accounts { + transfer( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + EXTRA_STAKE_AMOUNT, + ) + .await; + + last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + } + + let extra_lamports = EXTRA_STAKE_AMOUNT * stake_accounts.len() as u64; + let expected_fee = stake_pool.calc_epoch_fee_amount(extra_lamports).unwrap(); + + // Update epoch + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // Update list and pool + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check extra lamports are absorbed and fee'd as rewards + let post_balance = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(post_balance, pre_balance + extra_lamports); + let pool_token_supply = get_token_supply( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + assert_eq!(pool_token_supply, pre_token_supply + expected_fee); +} + +#[tokio::test] +async fn fail_with_wrong_validator_list() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let wrong_validator_list = Keypair::new(); + stake_pool_accounts.validator_list = wrong_validator_list; + let error = stake_pool_accounts + .update_stake_pool_balance(&mut banks_client, &payer, &recent_blockhash) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + ) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to update pool balance with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn fail_with_wrong_pool_fee_account() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let wrong_fee_account = Keypair::new(); + stake_pool_accounts.pool_fee_account = wrong_fee_account; + let error = stake_pool_accounts + .update_stake_pool_balance(&mut banks_client, &payer, &recent_blockhash) + .await + .unwrap() + .unwrap(); + + match error { + TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + ) => { + let program_error = StakePoolError::InvalidFeeAccount as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to update pool balance with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn fail_with_wrong_reserve() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let mut stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let wrong_reserve_stake = Keypair::new(); + stake_pool_accounts.reserve_stake = wrong_reserve_stake; + let error = stake_pool_accounts + .update_stake_pool_balance(&mut banks_client, &payer, &recent_blockhash) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidProgramAddress as u32), + ) + ); +} + +#[tokio::test] +async fn test_update_stake_pool_balance_with_uninitialized_validator_list() {} // TODO + +#[tokio::test] +async fn test_update_stake_pool_balance_with_out_of_dated_validators_balances() {} // TODO diff --git a/program/tests/update_validator_list_balance.rs b/program/tests/update_validator_list_balance.rs new file mode 100644 index 00000000..7d5d431a --- /dev/null +++ b/program/tests/update_validator_list_balance.rs @@ -0,0 +1,737 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{borsh1::try_from_slice_unchecked, program_pack::Pack}, + solana_program_test::*, + solana_sdk::{hash::Hash, signature::Signer, stake::state::StakeStateV2}, + spl_stake_pool::{ + state::{StakePool, StakeStatus, ValidatorList}, + MAX_VALIDATORS_TO_UPDATE, MINIMUM_RESERVE_LAMPORTS, + }, + spl_token::state::Mint, + std::num::NonZeroU32, +}; + +async fn setup( + num_validators: usize, +) -> ( + ProgramTestContext, + Hash, + StakePoolAccounts, + Vec, + Vec, + u64, + u64, + u64, +) { + let mut context = program_test().start_with_context().await; + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let mut slot = first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + let reserve_stake_amount = TEST_STAKE_AMOUNT * 2 * num_validators as u64; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + reserve_stake_amount + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + // Add several accounts with some stake + let mut stake_accounts: Vec = vec![]; + let mut deposit_accounts: Vec = vec![]; + for i in 0..num_validators { + let stake_account = ValidatorStakeAccount::new( + &stake_pool_accounts.stake_pool.pubkey(), + NonZeroU32::new(i as u32), + u64::MAX, + ); + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_account.validator, + &stake_account.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_account.stake_account, + &stake_account.vote.pubkey(), + stake_account.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let deposit_account = DepositStakeAccount::new_with_vote( + stake_account.vote.pubkey(), + stake_account.stake_account, + TEST_STAKE_AMOUNT, + ); + deposit_account + .create_and_delegate( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + stake_accounts.push(stake_account); + deposit_accounts.push(deposit_account); + } + + // Warp forward so the stakes properly activate, and deposit + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + for deposit_account in &mut deposit_accounts { + deposit_account + .deposit_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + ) + .await; + } + + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + ( + context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + deposit_accounts, + TEST_STAKE_AMOUNT, + reserve_stake_amount, + slot, + ) +} + +#[tokio::test] +async fn success_with_normal() { + let num_validators = 5; + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + validator_lamports, + reserve_lamports, + mut slot, + ) = setup(num_validators).await; + + // Check current balance in the list + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + let validator_list_sum = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(stake_pool.total_lamports, validator_list_sum); + // initially, have all of the deposits plus their rent, and the reserve stake + let initial_lamports = + (validator_lamports + stake_rent) * num_validators as u64 + reserve_lamports; + assert_eq!(validator_list_sum, initial_lamports); + + // Simulate rewards + for stake_account in &stake_accounts { + context.increment_vote_account_credits(&stake_account.vote.pubkey(), 100); + } + + // Warp one more epoch so the rewards are paid out + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + let new_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert!(new_lamports > initial_lamports); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(new_lamports, stake_pool.total_lamports); +} + +#[tokio::test] +async fn merge_into_reserve() { + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + lamports, + _, + mut slot, + ) = setup(MAX_VALIDATORS_TO_UPDATE).await; + + let pre_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + + let reserve_stake = context + .banks_client + .get_account(stake_pool_accounts.reserve_stake.pubkey()) + .await + .unwrap() + .unwrap(); + let pre_reserve_lamports = reserve_stake.lamports; + + println!("Decrease from all validators"); + for stake_account in &stake_accounts { + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + lamports, + stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + } + + println!("Update, should not change, no merges yet"); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(expected_lamports, stake_pool.total_lamports); + + println!("Warp one more epoch so the stakes deactivate"); + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); + + let reserve_stake = context + .banks_client + .get_account(stake_pool_accounts.reserve_stake.pubkey()) + .await + .unwrap() + .unwrap(); + let post_reserve_lamports = reserve_stake.lamports; + assert!(post_reserve_lamports > pre_reserve_lamports); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(expected_lamports, stake_pool.total_lamports); +} + +#[tokio::test] +async fn merge_into_validator_stake() { + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + lamports, + reserve_lamports, + mut slot, + ) = setup(MAX_VALIDATORS_TO_UPDATE).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let pre_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + + // Increase stake to all validators + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &last_blockhash, + ) + .await; + let available_lamports = + reserve_lamports - (stake_rent + current_minimum_delegation) * stake_accounts.len() as u64; + let increase_amount = available_lamports / stake_accounts.len() as u64; + for stake_account in &stake_accounts { + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.transient_stake_account, + &stake_account.stake_account, + &stake_account.vote.pubkey(), + increase_amount, + stake_account.transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + } + + // Warp just a little bit to get a new blockhash and update again + context.warp_to_slot(slot + 10).unwrap(); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // Update, should not change, no merges yet + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(expected_lamports, stake_pool.total_lamports); + + // Warp one more epoch so the stakes activate, ready to merge + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + let current_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(current_lamports, stake_pool.total_lamports); + + // Check that transient accounts are gone + for stake_account in &stake_accounts { + assert!(context + .banks_client + .get_account(stake_account.transient_stake_account) + .await + .unwrap() + .is_none()); + } + + // Check validator stake accounts have the expected balance now: + // validator stake account minimum + deposited lamports + rents + increased + // lamports + let expected_lamports = current_minimum_delegation + lamports + increase_amount + stake_rent; + for stake_account in &stake_accounts { + let validator_stake = + get_account(&mut context.banks_client, &stake_account.stake_account).await; + assert_eq!(validator_stake.lamports, expected_lamports); + } + + // Check reserve stake accounts for expected balance: + // own rent, other account rents, and 1 extra lamport + let reserve_stake = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + assert_eq!( + reserve_stake.lamports, + MINIMUM_RESERVE_LAMPORTS + stake_rent * (1 + stake_accounts.len() as u64) + ); +} + +#[tokio::test] +async fn merge_transient_stake_after_remove() { + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + lamports, + reserve_lamports, + mut slot, + ) = setup(1).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &last_blockhash, + ) + .await; + let deactivated_lamports = lamports; + // Decrease and remove all validators + for stake_account in &stake_accounts { + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + deactivated_lamports, + stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + } + + // Warp forward to merge time + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + // Update without merge, status should be DeactivatingTransient + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + true, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!(validator_list.validators.len(), 1); + assert_eq!( + validator_list.validators[0].status, + StakeStatus::DeactivatingAll.into() + ); + assert_eq!( + u64::from(validator_list.validators[0].active_stake_lamports), + stake_rent + current_minimum_delegation + ); + assert_eq!( + u64::from(validator_list.validators[0].transient_stake_lamports), + deactivated_lamports + stake_rent + ); + + // Update with merge, status should be ReadyForRemoval and no lamports + let error = stake_pool_accounts + .update_validator_list_balance( + &mut context.banks_client, + &context.payer, + &last_blockhash, + validator_list.validators.len(), + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // stake accounts were merged in, none exist anymore + for stake_account in &stake_accounts { + let not_found_account = context + .banks_client + .get_account(stake_account.stake_account) + .await + .unwrap(); + assert!(not_found_account.is_none()); + let not_found_account = context + .banks_client + .get_account(stake_account.transient_stake_account) + .await + .unwrap(); + assert!(not_found_account.is_none()); + } + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!(validator_list.validators.len(), 1); + assert_eq!( + validator_list.validators[0].status, + StakeStatus::ReadyForRemoval.into() + ); + assert_eq!(validator_list.validators[0].stake_lamports().unwrap(), 0); + + let reserve_stake = context + .banks_client + .get_account(stake_pool_accounts.reserve_stake.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!( + reserve_stake.lamports, + reserve_lamports + deactivated_lamports + stake_rent * 2 + MINIMUM_RESERVE_LAMPORTS + ); + + // Update stake pool balance and cleanup, should be gone + let error = stake_pool_accounts + .update_stake_pool_balance(&mut context.banks_client, &context.payer, &last_blockhash) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .cleanup_removed_validator_entries( + &mut context.banks_client, + &context.payer, + &last_blockhash, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!(validator_list.validators.len(), 0); +} + +#[tokio::test] +async fn success_with_burned_tokens() { + let num_validators = 1; + let (mut context, last_blockhash, stake_pool_accounts, _, deposit_accounts, _, _, mut slot) = + setup(num_validators).await; + + let mint_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + let mint = Mint::unpack(&mint_info.data).unwrap(); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(mint.supply, stake_pool.pool_token_supply); + + burn_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_mint.pubkey(), + &deposit_accounts[0].pool_account.pubkey(), + &deposit_accounts[0].authority, + deposit_accounts[0].pool_tokens, + ) + .await + .unwrap(); + + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + let mint_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.pool_mint.pubkey(), + ) + .await; + let mint = Mint::unpack(&mint_info.data).unwrap(); + assert_ne!(mint.supply, stake_pool.pool_token_supply); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + + assert_eq!(mint.supply, stake_pool.pool_token_supply); +} + +#[tokio::test] +async fn fail_with_uninitialized_validator_list() {} // TODO + +#[tokio::test] +async fn success_with_force_destaked_validator() {} diff --git a/program/tests/update_validator_list_balance_hijack.rs b/program/tests/update_validator_list_balance_hijack.rs new file mode 100644 index 00000000..66a2ab36 --- /dev/null +++ b/program/tests/update_validator_list_balance_hijack.rs @@ -0,0 +1,547 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +use spl_stake_pool::instruction; + +mod helpers; + +use { + helpers::*, + solana_program::{borsh1::try_from_slice_unchecked, pubkey::Pubkey, stake}, + solana_program_test::*, + solana_sdk::{ + hash::Hash, + instruction::InstructionError, + signature::Signer, + stake::state::{Authorized, Lockup, StakeStateV2}, + system_instruction, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError, find_stake_program_address, find_transient_stake_program_address, + find_withdraw_authority_program_address, id, state::StakePool, MINIMUM_RESERVE_LAMPORTS, + }, + std::num::NonZeroU32, +}; + +async fn setup( + num_validators: usize, +) -> ( + ProgramTestContext, + Hash, + StakePoolAccounts, + Vec, + Vec, + u64, + u64, + u64, +) { + let mut context = program_test().start_with_context().await; + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let mut slot = first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + let reserve_stake_amount = TEST_STAKE_AMOUNT * 2 * num_validators as u64; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + reserve_stake_amount + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + // Add several accounts with some stake + let mut stake_accounts: Vec = vec![]; + let mut deposit_accounts: Vec = vec![]; + for i in 0..num_validators { + let stake_account = ValidatorStakeAccount::new( + &stake_pool_accounts.stake_pool.pubkey(), + NonZeroU32::new(i as u32), + u64::MAX, + ); + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_account.validator, + &stake_account.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_account.stake_account, + &stake_account.vote.pubkey(), + stake_account.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let deposit_account = DepositStakeAccount::new_with_vote( + stake_account.vote.pubkey(), + stake_account.stake_account, + TEST_STAKE_AMOUNT, + ); + deposit_account + .create_and_delegate( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + stake_accounts.push(stake_account); + deposit_accounts.push(deposit_account); + } + + // Warp forward so the stakes properly activate, and deposit + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + for deposit_account in &mut deposit_accounts { + deposit_account + .deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + ) + .await; + } + + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + ( + context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + deposit_accounts, + TEST_STAKE_AMOUNT, + reserve_stake_amount, + slot, + ) +} + +#[tokio::test] +async fn success_ignoring_hijacked_transient_stake_with_authorized() { + let hijacker = Pubkey::new_unique(); + check_ignored_hijacked_transient_stake(Some(&Authorized::auto(&hijacker)), None).await; +} + +#[tokio::test] +async fn success_ignoring_hijacked_transient_stake_with_lockup() { + let hijacker = Pubkey::new_unique(); + check_ignored_hijacked_transient_stake( + None, + Some(&Lockup { + custodian: hijacker, + ..Lockup::default() + }), + ) + .await; +} + +async fn check_ignored_hijacked_transient_stake( + hijack_authorized: Option<&Authorized>, + hijack_lockup: Option<&Lockup>, +) { + let num_validators = 1; + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + lamports, + _, + mut slot, + ) = setup(num_validators).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let pre_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let (withdraw_authority, _) = + find_withdraw_authority_program_address(&id(), &stake_pool_accounts.stake_pool.pubkey()); + + println!("Decrease from all validators"); + let stake_account = &stake_accounts[0]; + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + lamports, + stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + println!("Warp one epoch so the stakes deactivate and merge"); + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + println!("During update, hijack the transient stake account"); + let validator_list = stake_pool_accounts + .get_validator_list(&mut context.banks_client) + .await; + let transient_stake_address = find_transient_stake_program_address( + &id(), + &stake_account.vote.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + stake_account.transient_stake_seed, + ) + .0; + let transaction = Transaction::new_signed_with_payer( + &[ + instruction::update_validator_list_balance_chunk( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_list, + 1, + 0, + /* no_merge = */ false, + ) + .unwrap(), + system_instruction::transfer( + &context.payer.pubkey(), + &transient_stake_address, + stake_rent + MINIMUM_RESERVE_LAMPORTS, + ), + stake::instruction::initialize( + &transient_stake_address, + hijack_authorized.unwrap_or(&Authorized::auto(&withdraw_authority)), + hijack_lockup.unwrap_or(&Lockup::default()), + ), + instruction::update_stake_pool_balance( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + ), + instruction::cleanup_removed_validator_entries( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer], + last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err(); + assert!(error.is_none(), "{:?}", error); + + println!("Update again normally, should be no change in the lamports"); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(pre_lamports, stake_pool.total_lamports); +} + +#[tokio::test] +async fn success_ignoring_hijacked_validator_stake_with_authorized() { + let hijacker = Pubkey::new_unique(); + check_ignored_hijacked_transient_stake(Some(&Authorized::auto(&hijacker)), None).await; +} + +#[tokio::test] +async fn success_ignoring_hijacked_validator_stake_with_lockup() { + let hijacker = Pubkey::new_unique(); + check_ignored_hijacked_validator_stake( + None, + Some(&Lockup { + custodian: hijacker, + ..Lockup::default() + }), + ) + .await; +} + +async fn check_ignored_hijacked_validator_stake( + hijack_authorized: Option<&Authorized>, + hijack_lockup: Option<&Lockup>, +) { + let num_validators = 1; + let ( + mut context, + last_blockhash, + stake_pool_accounts, + stake_accounts, + _, + lamports, + _, + mut slot, + ) = setup(num_validators).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let pre_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let (withdraw_authority, _) = + find_withdraw_authority_program_address(&id(), &stake_pool_accounts.stake_pool.pubkey()); + + let stake_account = &stake_accounts[0]; + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + lamports, + stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + println!("Warp one epoch so the stakes deactivate and merge"); + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + println!("During update, hijack the validator stake account"); + let validator_list = stake_pool_accounts + .get_validator_list(&mut context.banks_client) + .await; + let transaction = Transaction::new_signed_with_payer( + &[ + instruction::update_validator_list_balance_chunk( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_list, + 1, + 0, + /* no_merge = */ false, + ) + .unwrap(), + system_instruction::transfer( + &context.payer.pubkey(), + &stake_account.stake_account, + stake_rent + MINIMUM_RESERVE_LAMPORTS, + ), + stake::instruction::initialize( + &stake_account.stake_account, + hijack_authorized.unwrap_or(&Authorized::auto(&withdraw_authority)), + hijack_lockup.unwrap_or(&Lockup::default()), + ), + instruction::update_stake_pool_balance( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + ), + instruction::cleanup_removed_validator_entries( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer], + last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err(); + assert!(error.is_none(), "{:?}", error); + + println!("Update again normally, should be no change in the lamports"); + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(pre_lamports, stake_pool.total_lamports); + + println!("Fail adding validator back in with first seed"); + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account.stake_account, + &stake_account.vote.pubkey(), + stake_account.validator_stake_seed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::AlreadyInUse as u32), + ) + ); + + println!("Succeed adding validator back in with new seed"); + let seed = NonZeroU32::new(1); + let validator = stake_account.vote.pubkey(); + let (stake_account, _) = find_stake_program_address( + &id(), + &validator, + &stake_pool_accounts.stake_pool.pubkey(), + seed, + ); + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_account, + &validator, + seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let stake_pool_info = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = try_from_slice_unchecked::(&stake_pool_info.data).unwrap(); + assert_eq!(pre_lamports, stake_pool.total_lamports); + + let expected_lamports = get_validator_list_sum( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + assert_eq!(pre_lamports, expected_lamports); +} diff --git a/program/tests/vsa_add.rs b/program/tests/vsa_add.rs new file mode 100644 index 00000000..af6bd42e --- /dev/null +++ b/program/tests/vsa_add.rs @@ -0,0 +1,720 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + bincode::deserialize, + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + hash::Hash, + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + stake, system_program, sysvar, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{ + error::StakePoolError, find_stake_program_address, id, instruction, state, + MINIMUM_RESERVE_LAMPORTS, + }, +}; + +async fn setup( + num_validators: u64, +) -> ( + BanksClient, + Keypair, + Hash, + StakePoolAccounts, + ValidatorStakeAccount, +) { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let rent = banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = + stake_pool_get_minimum_delegation(&mut banks_client, &payer, &recent_blockhash).await; + let minimum_for_validator = stake_rent + current_minimum_delegation; + + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS + num_validators * minimum_for_validator, + ) + .await + .unwrap(); + + let validator_stake = + ValidatorStakeAccount::new(&stake_pool_accounts.stake_pool.pubkey(), None, 0); + create_vote( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + + ( + banks_client, + payer, + recent_blockhash, + stake_pool_accounts, + validator_stake, + ) +} + +#[tokio::test] +async fn success() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check if validator account was added to the list + let validator_list = get_account( + &mut banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let rent = banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = + stake_pool_get_minimum_delegation(&mut banks_client, &payer, &recent_blockhash).await; + assert_eq!( + validator_list, + state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![state::ValidatorStakeInfo { + status: state::StakeStatus::Active.into(), + vote_account_address: validator_stake.vote.pubkey(), + last_update_epoch: 0.into(), + active_stake_lamports: (stake_rent + current_minimum_delegation).into(), + transient_stake_lamports: 0.into(), + transient_seed_suffix: 0.into(), + unused: 0.into(), + validator_seed_suffix: validator_stake + .validator_stake_seed + .map(|s| s.get()) + .unwrap_or(0) + .into(), + }] + } + ); + + // Check stake account existence and authority + let stake = get_account(&mut banks_client, &validator_stake.stake_account).await; + let stake_state = deserialize::(&stake.data).unwrap(); + match stake_state { + stake::state::StakeStateV2::Stake(meta, _, _) => { + assert_eq!( + &meta.authorized.staker, + &stake_pool_accounts.withdraw_authority + ); + assert_eq!( + &meta.authorized.withdrawer, + &stake_pool_accounts.withdraw_authority + ); + } + _ => panic!(), + } +} + +#[tokio::test] +async fn fail_with_wrong_validator_list_account() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let wrong_validator_list = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::add_validator_to_pool( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.withdraw_authority, + &wrong_validator_list.pubkey(), + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.staker], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to add validator stake address with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn fail_double_add() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(2).await; + + stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + + let latest_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let transaction_error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &latest_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::ValidatorAlreadyAdded as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to add already added validator stake account"), + } +} + +#[tokio::test] +async fn fail_wrong_staker() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let malicious = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::add_validator_to_pool( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &malicious.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &malicious], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::WrongStaker as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to add validator stake account"), + } +} + +#[tokio::test] +async fn fail_without_signature() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new(validator_stake.stake_account, false), + AccountMeta::new(validator_stake.vote.pubkey(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::AddValidatorToPool( + validator_stake + .validator_stake_seed + .map(|s| s.get()) + .unwrap_or(0), + )) + .unwrap(), + }; + + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to add validator stake account without signing transaction"), + } +} + +#[tokio::test] +async fn fail_with_wrong_stake_program_id() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let wrong_stake_program = Pubkey::new_unique(); + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), true), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new(validator_stake.stake_account, false), + AccountMeta::new(validator_stake.vote.pubkey(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(wrong_stake_program, false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::AddValidatorToPool( + validator_stake + .validator_stake_seed + .map(|s| s.get()) + .unwrap_or(0), + )) + .unwrap(), + }; + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer, &stake_pool_accounts.staker], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!( + "Wrong error occurs while try to add validator stake account with wrong stake program ID" + ), + } +} + +#[tokio::test] +async fn fail_with_wrong_system_program_id() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let wrong_system_program = Pubkey::new_unique(); + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), true), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new(validator_stake.stake_account, false), + AccountMeta::new(validator_stake.vote.pubkey(), false), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + #[allow(deprecated)] + AccountMeta::new_readonly(stake::config::id(), false), + AccountMeta::new_readonly(wrong_system_program, false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::AddValidatorToPool( + validator_stake + .validator_stake_seed + .map(|s| s.get()) + .unwrap_or(0), + )) + .unwrap(), + }; + let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey())); + transaction.sign(&[&payer, &stake_pool_accounts.staker], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!( + "Wrong error occurs while try to add validator stake account with wrong stake program ID" + ), + } +} + +#[tokio::test] +async fn fail_add_too_many_validator_stake_accounts() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let rent = banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = + stake_pool_get_minimum_delegation(&mut banks_client, &payer, &recent_blockhash).await; + let minimum_for_validator = stake_rent + current_minimum_delegation; + + let stake_pool_accounts = StakePoolAccounts { + max_validators: 1, + ..Default::default() + }; + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + MINIMUM_RESERVE_LAMPORTS + 2 * minimum_for_validator, + ) + .await + .unwrap(); + + let validator_stake = + ValidatorStakeAccount::new(&stake_pool_accounts.stake_pool.pubkey(), None, 0); + create_vote( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_stake = + ValidatorStakeAccount::new(&stake_pool_accounts.stake_pool.pubkey(), None, 0); + create_vote( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::AccountDataTooSmall), + ); +} + +#[tokio::test] +async fn fail_with_unupdated_stake_pool() {} // TODO + +#[tokio::test] +async fn fail_with_uninitialized_validator_list_account() {} // TODO + +#[tokio::test] +async fn fail_on_non_vote_account() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, _) = setup(1).await; + + let validator = Pubkey::new_unique(); + let (stake_account, _) = find_stake_program_address( + &id(), + &validator, + &stake_pool_accounts.stake_pool.pubkey(), + None, + ); + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &stake_account, + &validator, + None, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::IncorrectProgramId,) + ); +} + +#[tokio::test] +async fn fail_on_incorrectly_derived_stake_account() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let bad_stake_account = Pubkey::new_unique(); + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &bad_stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidStakeAccountAddress as u32), + ) + ); +} + +#[tokio::test] +async fn success_with_lamports_in_account() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + transfer( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + 1_000_000, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check stake account existence and authority + let stake = get_account(&mut banks_client, &validator_stake.stake_account).await; + let stake_state = deserialize::(&stake.data).unwrap(); + match stake_state { + stake::state::StakeStateV2::Stake(meta, _, _) => { + assert_eq!( + &meta.authorized.staker, + &stake_pool_accounts.withdraw_authority + ); + assert_eq!( + &meta.authorized.withdrawer, + &stake_pool_accounts.withdraw_authority + ); + } + _ => panic!(), + } +} + +#[tokio::test] +async fn fail_with_not_enough_reserve_lamports() { + let (mut banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(0).await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::InsufficientFunds) + ); +} + +#[tokio::test] +async fn fail_with_wrong_reserve() { + let (banks_client, payer, recent_blockhash, stake_pool_accounts, validator_stake) = + setup(1).await; + + let wrong_reserve = Pubkey::new_unique(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::add_validator_to_pool( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &wrong_reserve, + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + )], + Some(&payer.pubkey()), + ); + transaction.sign(&[&payer, &stake_pool_accounts.staker], recent_blockhash); + let transaction_error = banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to add validator stake address with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn fail_with_draining_reserve() { + let (mut banks_client, payer, recent_blockhash) = program_test().start().await; + let current_minimum_delegation = + stake_pool_get_minimum_delegation(&mut banks_client, &payer, &recent_blockhash).await; + + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut banks_client, + &payer, + &recent_blockhash, + current_minimum_delegation, // add exactly enough for a validator + ) + .await + .unwrap(); + + let validator_stake = + ValidatorStakeAccount::new(&stake_pool_accounts.stake_pool.pubkey(), None, 0); + create_vote( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut banks_client, + &payer, + &recent_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::InsufficientFunds), + ); +} diff --git a/program/tests/vsa_remove.rs b/program/tests/vsa_remove.rs new file mode 100644 index 00000000..9cfb5bde --- /dev/null +++ b/program/tests/vsa_remove.rs @@ -0,0 +1,845 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + bincode::deserialize, + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + stake, system_instruction, sysvar, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{ + error::StakePoolError, find_transient_stake_program_address, id, instruction, state, + MINIMUM_RESERVE_LAMPORTS, + }, + std::num::NonZeroU32, +}; + +async fn setup() -> (ProgramTestContext, StakePoolAccounts, ValidatorStakeAccount) { + let mut context = program_test().start_with_context().await; + let stake_pool_accounts = StakePoolAccounts::default(); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + 10_000_000_000 + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let validator_stake = ValidatorStakeAccount::new( + &stake_pool_accounts.stake_pool.pubkey(), + NonZeroU32::new(u32::MAX), + u64::MAX, + ); + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.validator, + &validator_stake.vote, + ) + .await; + + let error = stake_pool_accounts + .add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + validator_stake.validator_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + (context, stake_pool_accounts, validator_stake) +} + +#[tokio::test] +async fn success() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check if account was removed from the list of stake accounts + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!( + validator_list, + state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![] + } + ); + + // Check stake account no longer exists + let account = context + .banks_client + .get_account(validator_stake.stake_account) + .await + .unwrap(); + assert!(account.is_none()); +} + +#[tokio::test] +async fn fail_with_wrong_stake_program_id() { + let (context, stake_pool_accounts, validator_stake) = setup().await; + + let wrong_stake_program = Pubkey::new_unique(); + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), true), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new(validator_stake.stake_account, false), + AccountMeta::new_readonly(validator_stake.transient_stake_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(wrong_stake_program, false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::RemoveValidatorFromPool).unwrap(), + }; + + let mut transaction = + Transaction::new_with_payer(&[instruction], Some(&context.payer.pubkey())); + transaction.sign( + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + error, + )) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to remove validator stake address with wrong stake program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_validator_list_account() { + let (context, stake_pool_accounts, validator_stake) = setup().await; + + let wrong_validator_list = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::remove_validator_from_pool( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.staker.pubkey(), + &stake_pool_accounts.withdraw_authority, + &wrong_validator_list.pubkey(), + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + )], + Some(&context.payer.pubkey()), + ); + transaction.sign( + &[&context.payer, &stake_pool_accounts.staker], + context.last_blockhash, + ); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to remove validator stake address with wrong validator stake list account"), + } +} + +#[tokio::test] +async fn success_at_large_value() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let threshold_amount = current_minimum_delegation * 1_000; + let _ = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake, + threshold_amount, + ) + .await + .unwrap(); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_double_remove() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::BorshIoError("Unknown".to_string()) + ) + ); +} + +#[tokio::test] +async fn fail_wrong_staker() { + let (context, stake_pool_accounts, validator_stake) = setup().await; + + let malicious = Keypair::new(); + + let mut transaction = Transaction::new_with_payer( + &[instruction::remove_validator_from_pool( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &malicious.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + )], + Some(&context.payer.pubkey()), + ); + transaction.sign(&[&context.payer, &malicious], context.last_blockhash); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::WrongStaker as u32; + assert_eq!(error_index, program_error); + } + _ => { + panic!("Wrong error occurs while not an staker try to remove validator stake address") + } + } +} + +#[tokio::test] +async fn fail_no_signature() { + let (context, stake_pool_accounts, validator_stake) = setup().await; + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.staker.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new(validator_stake.stake_account, false), + AccountMeta::new_readonly(validator_stake.transient_stake_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::RemoveValidatorFromPool).unwrap(), + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::SignatureMissing as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while malicious try to remove validator stake account without signing transaction"), + } +} + +#[tokio::test] +async fn success_with_activating_transient_stake() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + // increase the validator stake + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + 2_000_000_000, + validator_stake.transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // transient stake should be inactive now + let stake = get_account( + &mut context.banks_client, + &validator_stake.transient_stake_account, + ) + .await; + let stake_state = deserialize::(&stake.data).unwrap(); + assert_ne!( + stake_state.stake().unwrap().delegation.deactivation_epoch, + u64::MAX + ); +} + +#[tokio::test] +async fn success_with_deactivating_transient_stake() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + let deposit_info = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake, + TEST_STAKE_AMOUNT, + ) + .await + .unwrap(); + + // increase the validator stake + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + TEST_STAKE_AMOUNT + stake_rent, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // fail deposit + let maybe_deposit = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + &validator_stake, + TEST_STAKE_AMOUNT, + ) + .await; + assert!(maybe_deposit.is_none()); + + // fail withdraw + let user_stake_recipient = Keypair::new(); + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient, + ) + .await; + + let user_transfer_authority = Keypair::new(); + let new_authority = Pubkey::new_unique(); + delegate_tokens( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + &user_transfer_authority.pubkey(), + 1, + ) + .await; + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_authority, + 1, + ) + .await; + assert!(error.is_some()); + + // check validator has changed + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let expected_list = state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![state::ValidatorStakeInfo { + status: state::StakeStatus::DeactivatingAll.into(), + vote_account_address: validator_stake.vote.pubkey(), + last_update_epoch: 0.into(), + active_stake_lamports: (stake_rent + current_minimum_delegation).into(), + transient_stake_lamports: (TEST_STAKE_AMOUNT + stake_rent * 2).into(), + transient_seed_suffix: validator_stake.transient_stake_seed.into(), + unused: 0.into(), + validator_seed_suffix: validator_stake + .validator_stake_seed + .map(|s| s.get()) + .unwrap_or(0) + .into(), + }], + }; + assert_eq!(validator_list, expected_list); + + // Update will merge since activation and deactivation were in the same epoch + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let expected_list = state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![], + }; + assert_eq!(validator_list, expected_list); +} + +#[tokio::test] +async fn success_resets_preferred_validator() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Deposit, + Some(validator_stake.vote.pubkey()), + ) + .await; + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(validator_stake.vote.pubkey()), + ) + .await; + + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check if account was removed from the list of stake accounts + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!( + validator_list, + state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![] + } + ); + + // Check stake account no longer exists + let account = context + .banks_client + .get_account(validator_stake.stake_account) + .await + .unwrap(); + assert!(account.is_none()); +} + +#[tokio::test] +async fn success_with_hijacked_transient_account() { + let (mut context, stake_pool_accounts, validator_stake) = setup().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let current_minimum_delegation = stake_pool_get_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + let increase_amount = current_minimum_delegation + stake_rent; + + // increase stake on validator + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + increase_amount, + validator_stake.transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to merge + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + let mut slot = first_normal_slot + slots_per_epoch + 1; + context.warp_to_slot(slot).unwrap(); + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + // decrease + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + increase_amount, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to merge + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + // hijack + let validator_list = stake_pool_accounts + .get_validator_list(&mut context.banks_client) + .await; + let hijacker = Keypair::new(); + let transient_stake_address = find_transient_stake_program_address( + &id(), + &validator_stake.vote.pubkey(), + &stake_pool_accounts.stake_pool.pubkey(), + validator_stake.transient_stake_seed, + ) + .0; + let transaction = Transaction::new_signed_with_payer( + &[ + instruction::update_validator_list_balance_chunk( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &validator_list, + 1, + 0, + /* no_merge = */ false, + ) + .unwrap(), + system_instruction::transfer( + &context.payer.pubkey(), + &transient_stake_address, + current_minimum_delegation + stake_rent, + ), + stake::instruction::initialize( + &transient_stake_address, + &stake::state::Authorized { + staker: hijacker.pubkey(), + withdrawer: hijacker.pubkey(), + }, + &stake::state::Lockup::default(), + ), + instruction::update_stake_pool_balance( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.withdraw_authority, + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &spl_token::id(), + ), + instruction::cleanup_removed_validator_entries( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .err(); + assert!(error.is_none(), "{:?}", error); + + // activate transient stake account + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &transient_stake_address, + &hijacker, + &validator_stake.vote.pubkey(), + ) + .await; + + // Remove works even though transient account is activating + let error = stake_pool_accounts + .remove_validator_from_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to merge + slot += slots_per_epoch; + context.warp_to_slot(slot).unwrap(); + + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check if account was removed from the list of stake accounts + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + assert_eq!( + validator_list, + state::ValidatorList { + header: state::ValidatorListHeader { + account_type: state::AccountType::ValidatorList, + max_validators: stake_pool_accounts.max_validators, + }, + validators: vec![] + } + ); +} + +#[tokio::test] +async fn fail_not_updated_stake_pool() {} // TODO + +#[tokio::test] +async fn fail_with_uninitialized_validator_list_account() {} // TODO diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs new file mode 100644 index 00000000..3420a039 --- /dev/null +++ b/program/tests/withdraw.rs @@ -0,0 +1,840 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + sysvar, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + transport::TransportError, + }, + spl_stake_pool::{error::StakePoolError, id, instruction, state}, + spl_token::error::TokenError, + test_case::test_case, +}; + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success(token_program_id: Pubkey) { + _success(token_program_id, SuccessTestType::Success).await; +} + +#[tokio::test] +async fn success_with_closed_manager_fee_account() { + _success(spl_token::id(), SuccessTestType::UninitializedManagerFee).await; +} + +enum SuccessTestType { + Success, + UninitializedManagerFee, +} + +async fn _success(token_program_id: Pubkey, test_type: SuccessTestType) { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(token_program_id, 0).await; + + // Save stake pool state before withdrawal + let stake_pool_before = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool_before = + try_from_slice_unchecked::(stake_pool_before.data.as_slice()).unwrap(); + + // Check user recipient stake account balance + let initial_stake_lamports = + get_account(&mut context.banks_client, &user_stake_recipient.pubkey()) + .await + .lamports; + + // Save validator stake account record before withdrawal + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let validator_stake_item_before = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + + // Save user token balance + let user_token_balance_before = get_token_balance( + &mut context.banks_client, + &deposit_info.pool_account.pubkey(), + ) + .await; + + // Save pool fee token balance + let pool_fee_balance_before = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + + let destination_keypair = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &destination_keypair, + &stake_pool_accounts.pool_mint.pubkey(), + &Keypair::new(), + &[], + ) + .await + .unwrap(); + + if let SuccessTestType::UninitializedManagerFee = test_type { + transfer_spl_tokens( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &destination_keypair.pubkey(), + &stake_pool_accounts.manager, + pool_fee_balance_before, + stake_pool_accounts.pool_decimals, + ) + .await; + // Check that the account cannot be frozen due to lack of + // freeze authority. + let transaction_error = freeze_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.manager, + ) + .await + .unwrap_err(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::Custom(0x10)); + } + _ => panic!("Wrong error occurs while try to withdraw with wrong stake program ID"), + } + close_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &destination_keypair.pubkey(), + &stake_pool_accounts.manager, + ) + .await + .unwrap(); + } + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_withdraw, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check pool stats + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + // first and only deposit, lamports:pool 1:1 + let tokens_withdrawal_fee = match test_type { + SuccessTestType::Success => { + stake_pool_accounts.calculate_withdrawal_fee(tokens_to_withdraw) + } + _ => 0, + }; + let tokens_burnt = tokens_to_withdraw - tokens_withdrawal_fee; + assert_eq!( + stake_pool.total_lamports, + stake_pool_before.total_lamports - tokens_burnt + ); + assert_eq!( + stake_pool.pool_token_supply, + stake_pool_before.pool_token_supply - tokens_burnt + ); + + if let SuccessTestType::Success = test_type { + // Check manager received withdrawal fee + let pool_fee_balance = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + assert_eq!( + pool_fee_balance, + pool_fee_balance_before + tokens_withdrawal_fee, + ); + } + + // Check validator stake list storage + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let validator_stake_item = validator_list + .find(&validator_stake_account.vote.pubkey()) + .unwrap(); + assert_eq!( + validator_stake_item.stake_lamports().unwrap(), + validator_stake_item_before.stake_lamports().unwrap() - tokens_burnt + ); + assert_eq!( + u64::from(validator_stake_item.active_stake_lamports), + validator_stake_item.stake_lamports().unwrap(), + ); + + // Check tokens used + let user_token_balance = get_token_balance( + &mut context.banks_client, + &deposit_info.pool_account.pubkey(), + ) + .await; + assert_eq!( + user_token_balance, + user_token_balance_before - tokens_to_withdraw + ); + + // Check validator stake account balance + let validator_stake_account = get_account( + &mut context.banks_client, + &validator_stake_account.stake_account, + ) + .await; + assert_eq!( + validator_stake_account.lamports, + u64::from(validator_stake_item.active_stake_lamports) + ); + + // Check user recipient stake account balance + let user_stake_recipient_account = + get_account(&mut context.banks_client, &user_stake_recipient.pubkey()).await; + assert_eq!( + user_stake_recipient_account.lamports, + initial_stake_lamports + tokens_burnt + ); +} + +#[tokio::test] +async fn fail_with_wrong_stake_program() { + let ( + context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let new_authority = Pubkey::new_unique(); + let wrong_stake_program = Pubkey::new_unique(); + + let accounts = vec![ + AccountMeta::new(stake_pool_accounts.stake_pool.pubkey(), false), + AccountMeta::new(stake_pool_accounts.validator_list.pubkey(), false), + AccountMeta::new_readonly(stake_pool_accounts.withdraw_authority, false), + AccountMeta::new(validator_stake_account.stake_account, false), + AccountMeta::new(user_stake_recipient.pubkey(), false), + AccountMeta::new_readonly(new_authority, false), + AccountMeta::new_readonly(user_transfer_authority.pubkey(), true), + AccountMeta::new(deposit_info.pool_account.pubkey(), false), + AccountMeta::new(stake_pool_accounts.pool_fee_account.pubkey(), false), + AccountMeta::new(stake_pool_accounts.pool_mint.pubkey(), false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(wrong_stake_program, false), + ]; + let instruction = Instruction { + program_id: id(), + accounts, + data: borsh::to_vec(&instruction::StakePoolInstruction::WithdrawStake( + tokens_to_burn, + )) + .unwrap(), + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &user_transfer_authority], + context.last_blockhash, + ); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to withdraw with wrong stake program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let ( + mut context, + mut stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let new_authority = Pubkey::new_unique(); + stake_pool_accounts.withdraw_authority = Keypair::new().pubkey(); + + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::InvalidProgramAddress as u32; + assert_eq!(error_index, program_error); + } + _ => panic!("Wrong error occurs while try to withdraw with wrong withdraw authority"), + } +} + +#[tokio::test] +async fn fail_with_wrong_token_program_id() { + let ( + context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let new_authority = Pubkey::new_unique(); + let wrong_token_program = Keypair::new(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::withdraw_stake( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.validator_list.pubkey(), + &stake_pool_accounts.withdraw_authority, + &validator_stake_account.stake_account, + &user_stake_recipient.pubkey(), + &new_authority, + &user_transfer_authority.pubkey(), + &deposit_info.pool_account.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &wrong_token_program.pubkey(), + tokens_to_burn, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &user_transfer_authority], + context.last_blockhash, + ); + let transaction_error = context + .banks_client + .process_transaction(transaction) + .await + .err() + .unwrap() + .into(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::IncorrectProgramId); + } + _ => panic!("Wrong error occurs while try to withdraw with wrong token program ID"), + } +} + +#[tokio::test] +async fn fail_with_wrong_validator_list() { + let ( + mut context, + mut stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let new_authority = Pubkey::new_unique(); + stake_pool_accounts.validator_list = Keypair::new(); + + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = StakePoolError::InvalidValidatorStakeList as u32; + assert_eq!(error_index, program_error); + } + _ => panic!( + "Wrong error occurs while try to withdraw with wrong validator stake list account" + ), + } +} + +#[tokio::test] +async fn fail_with_unknown_validator() { + let ( + mut context, + stake_pool_accounts, + _, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let unknown_stake = create_unknown_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.stake_pool.pubkey(), + 0, + ) + .await; + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &unknown_stake.stake_account, + &new_authority, + tokens_to_withdraw, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::ValidatorNotFound as u32) + ) + ); +} + +#[tokio::test] +async fn fail_double_withdraw_to_the_same_account() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn / 2, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let latest_blockhash = context.banks_client.get_latest_blockhash().await.unwrap(); + + // Delegate tokens for burning + delegate_tokens( + &mut context.banks_client, + &context.payer, + &latest_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + &user_transfer_authority.pubkey(), + tokens_to_burn / 2, + ) + .await; + + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &latest_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn / 2, + ) + .await + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError(_, error)) => { + assert_eq!(error, InstructionError::InvalidAccountData); + } + _ => panic!("Wrong error occurs while try to do double withdraw"), + } +} + +#[tokio::test] +async fn fail_without_token_approval() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + revoke_tokens( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + ) + .await; + + let new_authority = Pubkey::new_unique(); + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap(); + + match transaction_error { + TransportError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(error_index), + )) => { + let program_error = TokenError::OwnerMismatch as u32; + assert_eq!(error_index, program_error); + } + _ => panic!( + "Wrong error occurs while try to do withdraw without token delegation for burn before" + ), + } +} + +#[tokio::test] +async fn fail_with_not_enough_tokens() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // Empty validator stake account + let empty_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &empty_stake_account.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::StakeLamportsNotEqualToMinimum as u32) + ), + ); + + // revoked delegation + revoke_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // generate a new authority each time to make each transaction unique + let new_authority = Pubkey::new_unique(); + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + transaction_error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32), + ) + ); + + // Delegate few tokens for burning + delegate_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &deposit_info.authority, + &user_transfer_authority.pubkey(), + 1, + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // generate a new authority each time to make each transaction unique + let new_authority = Pubkey::new_unique(); + let transaction_error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_burn, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + transaction_error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::InsufficientFunds as u32), + ) + ); +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success_with_slippage(token_program_id: Pubkey) { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(token_program_id, 0).await; + + // Save user token balance + let user_token_balance_before = get_token_balance( + &mut context.banks_client, + &deposit_info.pool_account.pubkey(), + ) + .await; + + // first and only deposit, lamports:pool 1:1 + let tokens_withdrawal_fee = stake_pool_accounts.calculate_withdrawal_fee(tokens_to_withdraw); + let received_lamports = tokens_to_withdraw - tokens_withdrawal_fee; + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_withdraw, + received_lamports + 1, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::ExceededSlippage as u32) + ) + ); + + let error = stake_pool_accounts + .withdraw_stake_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + tokens_to_withdraw, + received_lamports, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check tokens used + let user_token_balance = get_token_balance( + &mut context.banks_client, + &deposit_info.pool_account.pubkey(), + ) + .await; + assert_eq!( + user_token_balance, + user_token_balance_before - tokens_to_withdraw + ); +} diff --git a/program/tests/withdraw_edge_cases.rs b/program/tests/withdraw_edge_cases.rs new file mode 100644 index 00000000..8abdc2f8 --- /dev/null +++ b/program/tests/withdraw_edge_cases.rs @@ -0,0 +1,877 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + bincode::deserialize, + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, instruction::InstructionError, pubkey::Pubkey, stake, + }, + solana_program_test::*, + solana_sdk::{signature::Signer, transaction::TransactionError}, + spl_stake_pool::{error::StakePoolError, instruction, state}, + test_case::test_case, +}; + +#[tokio::test] +async fn fail_remove_validator() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + _, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + // decrease a little stake, not all + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports / 2, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + // update to merge deactivated stake into reserve + let error = stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Withdraw entire account, fail because some stake left + let validator_stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + let remaining_lamports = validator_stake_account.lamports; + let new_user_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_user_authority, + remaining_lamports, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::StakeLamportsNotEqualToMinimum as u32) + ) + ); +} + +#[test_case(0; "equal")] +#[test_case(5; "big")] +#[test_case(11; "bigger")] +#[test_case(29; "biggest")] +#[tokio::test] +async fn success_remove_validator(multiple: u64) { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + _, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + // make pool tokens very valuable, so it isn't possible to exactly get down to + // the minimum + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.reserve_stake.pubkey(), + deposit_info.stake_lamports * multiple, // each pool token is worth more than one lamport + ) + .await; + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let stake_pool = stake_pool_accounts + .get_stake_pool(&mut context.banks_client) + .await; + let lamports_per_pool_token = stake_pool.get_lamports_per_pool_token().unwrap(); + + // decrease all of stake except for lamports_per_pool_token lamports, must be + // withdrawable + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports + stake_rent - lamports_per_pool_token, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // update to merge deactivated stake into reserve + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let validator_stake_account = + get_account(&mut context.banks_client, &validator_stake.stake_account).await; + let remaining_lamports = validator_stake_account.lamports; + let stake_minimum_delegation = + stake_get_minimum_delegation(&mut context.banks_client, &context.payer, &last_blockhash) + .await; + // make sure it's actually more than the minimum + assert!(remaining_lamports > stake_rent + stake_minimum_delegation); + + // round up to force one more pool token if needed + let pool_tokens_post_fee = + (remaining_lamports * stake_pool.pool_token_supply + stake_pool.total_lamports - 1) + / stake_pool.total_lamports; + let new_user_authority = Pubkey::new_unique(); + let pool_tokens = stake_pool_accounts.calculate_inverse_withdrawal_fee(pool_tokens_post_fee); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_user_authority, + pool_tokens, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check validator stake account gone + let validator_stake_account = context + .banks_client + .get_account(validator_stake.stake_account) + .await + .unwrap(); + assert!(validator_stake_account.is_none()); + + // Check user recipient stake account balance + let user_stake_recipient_account = + get_account(&mut context.banks_client, &user_stake_recipient.pubkey()).await; + assert_eq!( + user_stake_recipient_account.lamports, + remaining_lamports + stake_rent + ); + + // Check that cleanup happens correctly + stake_pool_accounts + .cleanup_removed_validator_entries( + &mut context.banks_client, + &context.payer, + &last_blockhash, + ) + .await; + + let validator_list = get_account( + &mut context.banks_client, + &stake_pool_accounts.validator_list.pubkey(), + ) + .await; + let validator_list = + try_from_slice_unchecked::(validator_list.data.as_slice()).unwrap(); + let validator_stake_item = validator_list.find(&validator_stake.vote.pubkey()); + assert!(validator_stake_item.is_none()); +} + +#[tokio::test] +async fn fail_with_reserve() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + // decrease a little stake, not all + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports / 2, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + // update to merge deactivated stake into reserve + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + // Withdraw directly from reserve, fail because some stake left + let new_user_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &new_user_authority, + tokens_to_burn, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::StakeLamportsNotEqualToMinimum as u32) + ) + ); +} + +#[tokio::test] +async fn success_with_reserve() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + _, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // decrease all of stake + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.stake_account, + &validator_stake.transient_stake_account, + deposit_info.stake_lamports + stake_rent, + validator_stake.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + // update to merge deactivated stake into reserve + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + false, + ) + .await; + + // now it works + let new_user_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &stake_pool_accounts.reserve_stake.pubkey(), + &new_user_authority, + deposit_info.pool_tokens, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // first and only deposit, lamports:pool 1:1 + let stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let stake_pool = + try_from_slice_unchecked::(stake_pool.data.as_slice()).unwrap(); + // the entire deposit is actually stake since it isn't activated, so only + // the stake deposit fee is charged + let deposit_fee = stake_pool + .calc_pool_tokens_stake_deposit_fee(stake_rent + deposit_info.stake_lamports) + .unwrap(); + assert_eq!( + deposit_info.stake_lamports + stake_rent - deposit_fee, + deposit_info.pool_tokens, + "stake {} rent {} deposit fee {} pool tokens {}", + deposit_info.stake_lamports, + stake_rent, + deposit_fee, + deposit_info.pool_tokens + ); + + let withdrawal_fee = stake_pool_accounts.calculate_withdrawal_fee(deposit_info.pool_tokens); + + // Check tokens used + let user_token_balance = get_token_balance( + &mut context.banks_client, + &deposit_info.pool_account.pubkey(), + ) + .await; + assert_eq!(user_token_balance, 0); + + // Check reserve stake account balance + let reserve_stake_account = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await; + let stake_state = + deserialize::(&reserve_stake_account.data).unwrap(); + let meta = stake_state.meta().unwrap(); + assert_eq!( + meta.rent_exempt_reserve + withdrawal_fee + deposit_fee + stake_rent, + reserve_stake_account.lamports + ); + + // Check user recipient stake account balance + let user_stake_recipient_account = + get_account(&mut context.banks_client, &user_stake_recipient.pubkey()).await; + assert_eq!( + user_stake_recipient_account.lamports, + deposit_info.stake_lamports + stake_rent * 2 - withdrawal_fee - deposit_fee + ); +} + +#[tokio::test] +async fn success_with_empty_preferred_withdraw() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + // preferred is empty, withdrawing from non-preferred works + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_authority, + tokens_to_burn / 2, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn success_and_fail_with_preferred_withdraw() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + let _preferred_deposit = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + &preferred_validator, + TEST_STAKE_AMOUNT, + ) + .await + .unwrap(); + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_authority, + tokens_to_burn / 2 + 1, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::IncorrectWithdrawVoteAddress as u32) + ) + ); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // success from preferred + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &preferred_validator.stake_account, + &new_authority, + tokens_to_burn / 2, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_withdraw_from_transient() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // add a preferred withdraw validator, keep it empty, to be sure that this works + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + // decrease to minimum stake + 2 lamports + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake_account.stake_account, + &validator_stake_account.transient_stake_account, + deposit_info.stake_lamports + stake_rent - 2, + validator_stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // fail withdrawing from transient, still a lamport in the validator stake + // account + let new_user_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.transient_stake_account, + &new_user_authority, + tokens_to_withdraw, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidStakeAccountAddress as u32) + ) + ); +} + +#[tokio::test] +async fn success_withdraw_from_transient() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(spl_token::id(), STAKE_ACCOUNT_RENT_EXEMPTION).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // add a preferred withdraw validator, keep it empty, to be sure that this works + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // decrease all of stake + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &validator_stake_account.stake_account, + &validator_stake_account.transient_stake_account, + deposit_info.stake_lamports + stake_rent, + validator_stake_account.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // nothing left in the validator stake account (or any others), so withdrawing + // from the transient account is ok! + let new_user_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake_account.transient_stake_account, + &new_user_authority, + tokens_to_withdraw / 2, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn success_with_small_preferred_withdraw() { + let ( + mut context, + stake_pool_accounts, + validator_stake, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_burn, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // make pool tokens very valuable, so it isn't possible to exactly get down to + // the minimum + transfer( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.reserve_stake.pubkey(), + deposit_info.stake_lamports * 5, // each pool token is worth more than one lamport + ) + .await; + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + let preferred_validator = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + stake_pool_accounts + .set_preferred_validator( + &mut context.banks_client, + &context.payer, + &last_blockhash, + instruction::PreferredValidatorType::Withdraw, + Some(preferred_validator.vote.pubkey()), + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + // add a tiny bit of stake, less than lamports per pool token to preferred + // validator + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_exempt = rent.minimum_balance(std::mem::size_of::()); + let stake_minimum_delegation = + stake_get_minimum_delegation(&mut context.banks_client, &context.payer, &last_blockhash) + .await; + let minimum_lamports = stake_minimum_delegation + rent_exempt; + + simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + &preferred_validator, + stake_minimum_delegation + 1, // stake_rent gets deposited too + ) + .await + .unwrap(); + + // decrease all stake except for 1 lamport + let error = stake_pool_accounts + .decrease_validator_stake_either( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &preferred_validator.stake_account, + &preferred_validator.transient_stake_account, + minimum_lamports, + preferred_validator.transient_stake_seed, + DecreaseInstruction::Reserve, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // warp forward to deactivation + let first_normal_slot = context.genesis_config().epoch_schedule.first_normal_slot; + context.warp_to_slot(first_normal_slot + 1).unwrap(); + + // update to merge deactivated stake into reserve + stake_pool_accounts + .update_all( + &mut context.banks_client, + &context.payer, + &last_blockhash, + false, + ) + .await; + + // withdraw from preferred fails + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &preferred_validator.stake_account, + &new_authority, + 1, + ) + .await; + assert!(error.is_some()); + + // preferred is empty, withdrawing from non-preferred works + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &deposit_info.pool_account.pubkey(), + &validator_stake.stake_account, + &new_authority, + tokens_to_burn / 6, + ) + .await; + assert!(error.is_none(), "{:?}", error); +} diff --git a/program/tests/withdraw_sol.rs b/program/tests/withdraw_sol.rs new file mode 100644 index 00000000..623aa82c --- /dev/null +++ b/program/tests/withdraw_sol.rs @@ -0,0 +1,394 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program::{ + borsh1::try_from_slice_unchecked, instruction::InstructionError, pubkey::Pubkey, stake, + }, + solana_program_test::*, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, + }, + spl_stake_pool::{ + error::StakePoolError, + id, + instruction::{self, FundingType}, + state, MINIMUM_RESERVE_LAMPORTS, + }, + test_case::test_case, +}; + +async fn setup( + token_program_id: Pubkey, +) -> (ProgramTestContext, StakePoolAccounts, Keypair, Pubkey, u64) { + let mut context = program_test().start_with_context().await; + + let stake_pool_accounts = StakePoolAccounts::new_with_token_program(token_program_id); + stake_pool_accounts + .initialize_stake_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + MINIMUM_RESERVE_LAMPORTS, + ) + .await + .unwrap(); + + let user = Keypair::new(); + + // make pool token account for user + let pool_token_account = Keypair::new(); + create_token_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts.token_program_id, + &pool_token_account, + &stake_pool_accounts.pool_mint.pubkey(), + &user, + &[], + ) + .await + .unwrap(); + + let error = stake_pool_accounts + .deposit_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &pool_token_account.pubkey(), + TEST_STAKE_AMOUNT, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + let tokens_issued = + get_token_balance(&mut context.banks_client, &pool_token_account.pubkey()).await; + + ( + context, + stake_pool_accounts, + user, + pool_token_account.pubkey(), + tokens_issued, + ) +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success(token_program_id: Pubkey) { + let (mut context, stake_pool_accounts, user, pool_token_account, pool_tokens) = + setup(token_program_id).await; + + // Save stake pool state before withdrawing + let pre_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let pre_stake_pool = + try_from_slice_unchecked::(pre_stake_pool.data.as_slice()).unwrap(); + + // Save reserve state before withdrawing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let error = stake_pool_accounts + .withdraw_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + None, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Stake pool should add its balance to the pool balance + let post_stake_pool = get_account( + &mut context.banks_client, + &stake_pool_accounts.stake_pool.pubkey(), + ) + .await; + let post_stake_pool = + try_from_slice_unchecked::(post_stake_pool.data.as_slice()).unwrap(); + let amount_withdrawn_minus_fee = + pool_tokens - stake_pool_accounts.calculate_withdrawal_fee(pool_tokens); + assert_eq!( + post_stake_pool.total_lamports, + pre_stake_pool.total_lamports - amount_withdrawn_minus_fee + ); + assert_eq!( + post_stake_pool.pool_token_supply, + pre_stake_pool.pool_token_supply - amount_withdrawn_minus_fee + ); + + // Check minted tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + assert_eq!(user_token_balance, 0); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports - amount_withdrawn_minus_fee + ); +} + +#[tokio::test] +async fn fail_with_wrong_withdraw_authority() { + let (mut context, mut stake_pool_accounts, user, pool_token_account, pool_tokens) = + setup(spl_token::id()).await; + + stake_pool_accounts.withdraw_authority = Pubkey::new_unique(); + + let error = stake_pool_accounts + .withdraw_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + None, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidProgramAddress as u32) + ) + ); +} + +#[tokio::test] +async fn fail_overdraw_reserve() { + let (mut context, stake_pool_accounts, user, pool_token_account, _) = + setup(spl_token::id()).await; + + // add a validator and increase stake to drain the reserve + let validator_stake = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let stake_rent = rent.minimum_balance(std::mem::size_of::()); + let error = stake_pool_accounts + .increase_validator_stake( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &validator_stake.transient_stake_account, + &validator_stake.stake_account, + &validator_stake.vote.pubkey(), + TEST_STAKE_AMOUNT - stake_rent, + validator_stake.transient_stake_seed, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // try to withdraw one lamport after fees, will overdraw + let error = stake_pool_accounts + .withdraw_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + 2, + None, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::SolWithdrawalTooLarge as u32) + ) + ); +} + +#[tokio::test] +async fn success_with_sol_withdraw_authority() { + let (mut context, stake_pool_accounts, user, pool_token_account, pool_tokens) = + setup(spl_token::id()).await; + let sol_withdraw_authority = Keypair::new(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&sol_withdraw_authority.pubkey()), + FundingType::SolWithdraw, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let error = stake_pool_accounts + .withdraw_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + Some(&sol_withdraw_authority), + ) + .await; + assert!(error.is_none(), "{:?}", error); +} + +#[tokio::test] +async fn fail_without_sol_withdraw_authority_signature() { + let (mut context, stake_pool_accounts, user, pool_token_account, pool_tokens) = + setup(spl_token::id()).await; + let sol_withdraw_authority = Keypair::new(); + + let transaction = Transaction::new_signed_with_payer( + &[instruction::set_funding_authority( + &id(), + &stake_pool_accounts.stake_pool.pubkey(), + &stake_pool_accounts.manager.pubkey(), + Some(&sol_withdraw_authority.pubkey()), + FundingType::SolWithdraw, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &stake_pool_accounts.manager], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let wrong_withdrawer = Keypair::new(); + let error = stake_pool_accounts + .withdraw_sol( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + Some(&wrong_withdrawer), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::InvalidSolWithdrawAuthority as u32) + ) + ); +} + +#[test_case(spl_token::id(); "token")] +#[test_case(spl_token_2022::id(); "token-2022")] +#[tokio::test] +async fn success_with_slippage(token_program_id: Pubkey) { + let (mut context, stake_pool_accounts, user, pool_token_account, pool_tokens) = + setup(token_program_id).await; + + let amount_received = pool_tokens - stake_pool_accounts.calculate_withdrawal_fee(pool_tokens); + + // Save reserve state before withdrawing + let pre_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + + let error = stake_pool_accounts + .withdraw_sol_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + amount_received + 1, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(StakePoolError::ExceededSlippage as u32) + ) + ); + + let error = stake_pool_accounts + .withdraw_sol_with_slippage( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &user, + &pool_token_account, + pool_tokens, + amount_received, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check burned tokens + let user_token_balance = + get_token_balance(&mut context.banks_client, &pool_token_account).await; + assert_eq!(user_token_balance, 0); + + // Check reserve + let post_reserve_lamports = get_account( + &mut context.banks_client, + &stake_pool_accounts.reserve_stake.pubkey(), + ) + .await + .lamports; + assert_eq!( + post_reserve_lamports, + pre_reserve_lamports - amount_received + ); +} diff --git a/program/tests/withdraw_with_fee.rs b/program/tests/withdraw_with_fee.rs new file mode 100644 index 00000000..7a8e000c --- /dev/null +++ b/program/tests/withdraw_with_fee.rs @@ -0,0 +1,223 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + bincode::deserialize, + helpers::*, + solana_program::{pubkey::Pubkey, stake}, + solana_program_test::*, + solana_sdk::signature::{Keypair, Signer}, + spl_stake_pool::minimum_stake_lamports, +}; + +#[tokio::test] +async fn success_withdraw_all_fee_tokens() { + let ( + mut context, + stake_pool_accounts, + validator_stake_account, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // move tokens to fee account + transfer_spl_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &stake_pool_accounts.pool_fee_account.pubkey(), + &user_transfer_authority, + tokens_to_withdraw / 2, + stake_pool_accounts.pool_decimals, + ) + .await; + + let fee_tokens = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + + let user_transfer_authority = Keypair::new(); + delegate_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &stake_pool_accounts.pool_fee_account.pubkey(), + &stake_pool_accounts.manager, + &user_transfer_authority.pubkey(), + fee_tokens, + ) + .await; + + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &stake_pool_accounts.pool_fee_account.pubkey(), + &validator_stake_account.stake_account, + &new_authority, + fee_tokens, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check balance is 0 + let fee_tokens = get_token_balance( + &mut context.banks_client, + &stake_pool_accounts.pool_fee_account.pubkey(), + ) + .await; + assert_eq!(fee_tokens, 0); +} + +#[tokio::test] +async fn success_empty_out_stake_with_fee() { + let ( + mut context, + stake_pool_accounts, + _, + deposit_info, + user_transfer_authority, + user_stake_recipient, + tokens_to_withdraw, + ) = setup_for_withdraw(spl_token::id(), 0).await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); + + // add another validator and deposit into it + let other_validator_stake_account = simple_add_validator_to_pool( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + None, + ) + .await; + + let other_deposit_info = simple_deposit_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts, + &other_validator_stake_account, + TEST_STAKE_AMOUNT, + ) + .await + .unwrap(); + + // move tokens to new account + transfer_spl_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &deposit_info.pool_account.pubkey(), + &stake_pool_accounts.pool_mint.pubkey(), + &other_deposit_info.pool_account.pubkey(), + &user_transfer_authority, + tokens_to_withdraw, + stake_pool_accounts.pool_decimals, + ) + .await; + + let user_tokens = get_token_balance( + &mut context.banks_client, + &other_deposit_info.pool_account.pubkey(), + ) + .await; + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + + let user_transfer_authority = Keypair::new(); + delegate_tokens( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &stake_pool_accounts.token_program_id, + &other_deposit_info.pool_account.pubkey(), + &other_deposit_info.authority, + &user_transfer_authority.pubkey(), + user_tokens, + ) + .await; + + // calculate exactly how much to withdraw, given the fee, to get the account + // down to 0, using an inverse fee calculation + let validator_stake_account = get_account( + &mut context.banks_client, + &other_validator_stake_account.stake_account, + ) + .await; + let stake_state = + deserialize::(&validator_stake_account.data).unwrap(); + let meta = stake_state.meta().unwrap(); + let stake_minimum_delegation = + stake_get_minimum_delegation(&mut context.banks_client, &context.payer, &last_blockhash) + .await; + let lamports_to_withdraw = + validator_stake_account.lamports - minimum_stake_lamports(&meta, stake_minimum_delegation); + let pool_tokens_to_withdraw = + stake_pool_accounts.calculate_inverse_withdrawal_fee(lamports_to_withdraw); + + let last_blockhash = context + .banks_client + .get_new_latest_blockhash(&last_blockhash) + .await + .unwrap(); + let new_authority = Pubkey::new_unique(); + let error = stake_pool_accounts + .withdraw_stake( + &mut context.banks_client, + &context.payer, + &last_blockhash, + &user_stake_recipient.pubkey(), + &user_transfer_authority, + &other_deposit_info.pool_account.pubkey(), + &other_validator_stake_account.stake_account, + &new_authority, + pool_tokens_to_withdraw, + ) + .await; + assert!(error.is_none(), "{:?}", error); + + // Check balance of validator stake account is MINIMUM + rent-exemption + let validator_stake_account = get_account( + &mut context.banks_client, + &other_validator_stake_account.stake_account, + ) + .await; + let stake_state = + deserialize::(&validator_stake_account.data).unwrap(); + let meta = stake_state.meta().unwrap(); + assert_eq!( + validator_stake_account.lamports, + minimum_stake_lamports(&meta, stake_minimum_delegation) + ); +}