diff --git a/README.md b/README.md index 3fd1eef6..9b8bba71 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,9 @@ -# single-pool -The SPL Single Pool program and its clients +## Solana Program Library Single-Validator Stake Pool + +The single-validator stake pool program is an upcoming SPL program that enables liquid staking with zero fees, no counterparty, and 100% capital efficiency. + +The program defines a canonical pool for every vote account, which can be initialized permissionlessly, and mints tokens in exchange for stake delegated to its designated validator. + +The program is a stripped-down adaptation of the existing multi-validator stake pool program, with approximately 80% less code, to minimize execution risk. + +On launch, users will only be able to deposit and withdraw active stake, but pending future stake program development, we hope to support instant sol deposits and withdrawals as well. diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml new file mode 100644 index 00000000..133d255f --- /dev/null +++ b/clients/cli/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "spl-single-pool-cli" +version = "1.0.0" +description = "Solana Program Library Single-Validator Stake Pool Command-line Utility" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[dependencies] +tokio = "1.42" +clap = { version = "3.2.23", features = ["derive"] } +console = "0.15.8" +borsh = "1.5.3" +bincode = "1.3.1" +serde = "1.0.215" +serde_derive = "1.0.103" +serde_json = "1.0.133" +serde_with = "3.11.0" +solana-account-decoder = "2.1.0" +solana-clap-v3-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-remote-wallet = "2.1.0" +solana-sdk = "2.1.0" +solana-transaction-status = "2.1.0" +solana-vote-program = "2.1.0" +spl-token = { version = "7.0", path = "../../token/program", features = [ + "no-entrypoint", +] } +spl-token-client = { version = "0.13.0", path = "../../token/client" } +spl-single-pool = { version = "1.0.0", path = "../program", features = [ + "no-entrypoint", +] } + +[dev-dependencies] +solana-test-validator = "2.1.0" +serial_test = "3.2.0" +test-case = "3.3" +tempfile = "3.14.0" + +[[bin]] +name = "spl-single-pool" +path = "src/main.rs" diff --git a/clients/cli/src/cli.rs b/clients/cli/src/cli.rs new file mode 100644 index 00000000..ae6351fc --- /dev/null +++ b/clients/cli/src/cli.rs @@ -0,0 +1,473 @@ +use { + crate::config::Error, + clap::{ + builder::{PossibleValuesParser, TypedValueParser}, + ArgGroup, ArgMatches, Args, Parser, Subcommand, + }, + solana_clap_v3_utils::{ + input_parsers::{parse_url_or_moniker, Amount}, + input_validators::{is_valid_pubkey, is_valid_signer}, + keypair::{pubkey_from_path, signer_from_path}, + }, + solana_cli_output::OutputFormat, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_sdk::{pubkey::Pubkey, signer::Signer}, + spl_single_pool::{self, find_pool_address}, + std::{rc::Rc, str::FromStr, sync::Arc}, +}; + +#[derive(Clone, Debug, Parser)] +#[clap(author, version, about, long_about = None)] +pub struct Cli { + /// Configuration file to use + #[clap(global(true), short = 'C', long = "config", id = "PATH")] + pub config_file: Option, + + /// Show additional information + #[clap(global(true), short, long)] + pub verbose: bool, + + /// Simulate transaction instead of executing + #[clap(global(true), long, alias = "dryrun")] + pub dry_run: bool, + + /// URL for Solana's JSON RPC or moniker (or their first letter): + /// [mainnet-beta, testnet, devnet, localhost]. + /// Default from the configuration file. + #[clap( + global(true), + short = 'u', + long = "url", + id = "URL_OR_MONIKER", + value_parser = parse_url_or_moniker, + )] + pub json_rpc_url: Option, + + /// Specify the fee-payer account. This may be a keypair file, the ASK + /// keyword or the pubkey of an offline signer, provided an appropriate + /// --signer argument is also passed. Defaults to the client keypair. + #[clap( + global(true), + long, + id = "PAYER_KEYPAIR", + validator = |s| is_valid_signer(s), + )] + pub fee_payer: Option, + + /// Return information in specified output format + #[clap( + global(true), + long = "output", + id = "FORMAT", + conflicts_with = "verbose", + value_parser = PossibleValuesParser::new(["json", "json-compact"]).map(|o| parse_output_format(&o)), + )] + pub output_format: Option, + + #[clap(subcommand)] + pub command: Command, +} + +#[derive(Clone, Debug, Subcommand)] +pub enum Command { + /// Commands used to initialize or manage existing single-validator stake + /// pools. Other than initializing new pools, most users should never + /// need to use these. + Manage(ManageCli), + + /// Deposit delegated stake into a pool in exchange for pool tokens, closing + /// out the original stake account. Provide either a stake account + /// address, or a pool or vote account address along with the + /// --default-stake-account flag to use an account created with + /// create-stake. + Deposit(DepositCli), + + /// Withdraw stake into a new stake account, burning tokens in exchange. + /// Provide either pool or vote account address, plus either an amount of + /// tokens to burn or the ALL keyword to burn all. + Withdraw(WithdrawCli), + + /// Create and delegate a new stake account to a given validator, using a + /// default address linked to the intended depository pool + CreateDefaultStake(CreateStakeCli), + + /// Display info for one or all single-validator stake pool(s) + Display(DisplayCli), +} + +#[derive(Clone, Debug, Parser)] +pub struct ManageCli { + #[clap(subcommand)] + pub manage: ManageCommand, +} + +#[derive(Clone, Debug, Subcommand)] +pub enum ManageCommand { + /// Permissionlessly create the single-validator stake pool for a given + /// validator vote account if one does not already exist. The fee payer + /// also pays rent-exemption for accounts, along with the + /// cluster-configured minimum stake delegation + Initialize(InitializeCli), + + /// Permissionlessly re-stake the pool stake account in the case when it has + /// been deactivated. This may happen if the validator is + /// force-deactivated, and then later reactivated using the same address + /// for its vote account. + ReactivatePoolStake(ReactivateCli), + + /// Permissionlessly create default MPL token metadata for the pool mint. + /// Normally this is done automatically upon initialization, so this + /// does not need to be called. + CreateTokenMetadata(CreateMetadataCli), + + /// Modify the MPL token metadata associated with the pool mint. This action + /// can only be performed by the validator vote account's withdraw + /// authority + UpdateTokenMetadata(UpdateMetadataCli), +} + +#[derive(Clone, Debug, Args)] +pub struct InitializeCli { + /// The vote account to create the pool for + #[clap(value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Pubkey, + + /// Do not create MPL metadata for the pool mint + #[clap(long)] + pub skip_metadata: bool, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct ReactivateCli { + /// The pool to reactivate + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to reactivate + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + // backdoor for testing, theres no reason to ever use this + #[clap(long, hide = true)] + pub skip_deactivation_check: bool, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(ArgGroup::new("stake-source").required(true).args(&["stake-account-address", "default-stake-account"])))] +#[clap(group(pool_source_group().required(false)))] +pub struct DepositCli { + /// The stake account to deposit from. Must be in the same activation state + /// as the pool's stake account + #[clap(value_parser = |p: &str| parse_address(p, "stake_account_address"))] + pub stake_account_address: Option, + + /// Instead of using a stake account by address, use the user's default + /// account for a specified pool + #[clap( + short, + long, + conflicts_with = "stake-account-address", + requires = "pool-source" + )] + pub default_stake_account: bool, + + /// The pool to deposit into. Optional when stake account is provided + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to deposit into. Optional + /// when stake account or pool is provided + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// Signing authority on the stake account to be deposited. Defaults to the + /// client keypair + #[clap(long = "withdraw-authority", id = "STAKE_WITHDRAW_AUTHORITY_KEYPAIR", validator = |s| is_valid_signer(s))] + pub stake_withdraw_authority: Option, + + /// The token account to mint to. Defaults to the client keypair's + /// associated token account + #[clap(long = "token-account", value_parser = |p: &str| parse_address(p, "token_account_address"))] + pub token_account_address: Option, + + /// The wallet to refund stake account rent to. Defaults to the client + /// keypair's pubkey + #[clap(long = "recipient", value_parser = |p: &str| parse_address(p, "lamport_recipient_address"))] + pub lamport_recipient_address: Option, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct WithdrawCli { + /// Amount of tokens to burn for withdrawal + #[clap(value_parser = Amount::parse_decimal_or_all)] + pub token_amount: Amount, + + /// The token account to withdraw from. Defaults to the associated token + /// account for the pool mint + #[clap(long = "token-account", value_parser = |p: &str| parse_address(p, "token_account_address"))] + pub token_account_address: Option, + + /// The pool to withdraw from + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to withdraw from + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// Signing authority on the token account. Defaults to the client keypair + #[clap(long = "token-authority", id = "TOKEN_AUTHORITY_KEYPAIR", validator = |s| is_valid_signer(s))] + pub token_authority: Option, + + /// Authority to assign to the new stake account. Defaults to the pubkey of + /// the client keypair + #[clap(long = "stake-authority", value_parser = |p: &str| parse_address(p, "stake_authority_address"))] + pub stake_authority_address: Option, + + /// Deactivate stake account after withdrawal + #[clap(long)] + pub deactivate: bool, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct CreateMetadataCli { + /// The pool to create default MPL token metadata for + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to create metadata for + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct UpdateMetadataCli { + /// New name for the pool token + #[clap(validator = is_valid_token_name)] + pub token_name: String, + + /// New ticker symbol for the pool token + #[clap(validator = is_valid_token_symbol)] + pub token_symbol: String, + + /// Optional external URI for the pool token. Leaving this argument blank + /// will clear any existing value + #[clap(validator = is_valid_token_uri)] + pub token_uri: Option, + + /// The pool to change MPL token metadata for + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to create metadata for + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// Authorized withdrawer for the vote account, to prove validator + /// ownership. Defaults to the client keypair + #[clap(long, id = "AUTHORIZED_WITHDRAWER_KEYPAIR", validator = |s| is_valid_signer(s))] + pub authorized_withdrawer: Option, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group()))] +pub struct CreateStakeCli { + /// Number of lamports to stake + pub lamports: u64, + + /// The pool to create a stake account for + #[clap(short, long = "pool", value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to create stake for + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// Authority to assign to the new stake account. Defaults to the pubkey of + /// the client keypair + #[clap(long = "stake-authority", value_parser = |p: &str| parse_address(p, "stake_authority_address"))] + pub stake_authority_address: Option, +} + +#[derive(Clone, Debug, Args)] +#[clap(group(pool_source_group().arg("all")))] +pub struct DisplayCli { + /// The pool to display + #[clap(value_parser = |p: &str| parse_address(p, "pool_address"))] + pub pool_address: Option, + + /// The vote account corresponding to the pool to display + #[clap(long = "vote-account", value_parser = |p: &str| parse_address(p, "vote_account_address"))] + pub vote_account_address: Option, + + /// Display all pools + #[clap(long)] + pub all: bool, +} + +fn pool_source_group() -> ArgGroup<'static> { + ArgGroup::new("pool-source") + .required(true) + .args(&["pool-address", "vote-account-address"]) +} + +pub fn parse_address(path: &str, name: &str) -> Result { + if is_valid_pubkey(path).is_ok() { + // this all is ugly but safe + // wallet_manager doesn't need to be shared, it just saves cycles to cache it + // and the only way argmatches default fails with an unchecked lookup is in the + // prompt branch which seems unlikely to ever be used for pubkeys + // the usb lookup in signer_from_path_with_config is safe + // and the pubkey lookups are unreachable because pubkey_from_path short + // circuits that case + let mut wallet_manager = None; + pubkey_from_path(&ArgMatches::default(), path, name, &mut wallet_manager) + .map_err(|_| format!("Failed to load pubkey {} at {}", name, path)) + } else { + Err(format!("Failed to parse pubkey {} at {}", name, path)) + } +} + +pub fn parse_output_format(output_format: &str) -> OutputFormat { + match output_format { + "json" => OutputFormat::Json, + "json-compact" => OutputFormat::JsonCompact, + _ => unreachable!(), + } +} + +pub fn is_valid_token_name(s: &str) -> Result<(), String> { + if s.len() > 32 { + Err("Maximum token name length is 32 characters".to_string()) + } else { + Ok(()) + } +} + +pub fn is_valid_token_symbol(s: &str) -> Result<(), String> { + if s.len() > 10 { + Err("Maximum token symbol length is 10 characters".to_string()) + } else { + Ok(()) + } +} + +pub fn is_valid_token_uri(s: &str) -> Result<(), String> { + if s.len() > 200 { + Err("Maximum token URI length is 200 characters".to_string()) + } else { + Ok(()) + } +} + +pub fn pool_address_from_args(maybe_pool: Option, maybe_vote: Option) -> Pubkey { + if let Some(pool_address) = maybe_pool { + pool_address + } else if let Some(vote_account_address) = maybe_vote { + find_pool_address(&spl_single_pool::id(), &vote_account_address) + } else { + unreachable!() + } +} + +// all this is because solana clap v3 utils signer handlers dont work with +// derive syntax which means its impossible to parse keypairs or addresses in +// value_parser instead, we take the input into a string wrapper from the cli +// and then once the first pass is over, we do a second manual pass converting +// to signer wrappers +#[derive(Clone, Debug)] +pub enum SignerArg { + Source(String), + Signer(Arc), +} +impl FromStr for SignerArg { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::Source(s.to_string())) + } +} +impl PartialEq for SignerArg { + fn eq(&self, other: &SignerArg) -> bool { + match (self, other) { + (SignerArg::Source(ref a), SignerArg::Source(ref b)) => a == b, + (SignerArg::Signer(ref a), SignerArg::Signer(ref b)) => a == b, + (_, _) => false, + } + } +} + +pub fn signer_from_arg( + signer_arg: Option, + default_signer: &Arc, +) -> Result, Error> { + match signer_arg { + Some(SignerArg::Signer(signer)) => Ok(signer), + Some(SignerArg::Source(_)) => Err("Signer arg string must be converted to signer".into()), + None => Ok(default_signer.clone()), + } +} + +impl Command { + pub fn with_signers( + mut self, + matches: &ArgMatches, + wallet_manager: &mut Option>, + ) -> Result { + match self { + Command::Deposit(ref mut config) => { + config.stake_withdraw_authority = with_signer( + matches, + wallet_manager, + config.stake_withdraw_authority.clone(), + "stake_authority", + )?; + } + Command::Withdraw(ref mut config) => { + config.token_authority = with_signer( + matches, + wallet_manager, + config.token_authority.clone(), + "token_authority", + )?; + } + Command::Manage(ManageCli { + manage: ManageCommand::UpdateTokenMetadata(ref mut config), + }) => { + config.authorized_withdrawer = with_signer( + matches, + wallet_manager, + config.authorized_withdrawer.clone(), + "authorized_withdrawer", + )?; + } + _ => (), + } + + Ok(self) + } +} + +pub fn with_signer( + matches: &ArgMatches, + wallet_manager: &mut Option>, + arg: Option, + name: &str, +) -> Result, Error> { + Ok(match arg { + Some(SignerArg::Source(path)) => { + let signer = if let Ok(signer) = signer_from_path(matches, &path, name, wallet_manager) + { + signer + } else { + return Err(format!("Cannot parse signer {} / {}", name, path).into()); + }; + Some(SignerArg::Signer(Arc::from(signer))) + } + a => a, + }) +} diff --git a/clients/cli/src/config.rs b/clients/cli/src/config.rs new file mode 100644 index 00000000..649c2c7e --- /dev/null +++ b/clients/cli/src/config.rs @@ -0,0 +1,129 @@ +use { + crate::cli::*, + clap::ArgMatches, + solana_clap_v3_utils::keypair::signer_from_path, + solana_cli_output::OutputFormat, + solana_client::nonblocking::rpc_client::RpcClient, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_sdk::{commitment_config::CommitmentConfig, signature::Signer}, + spl_token_client::client::{ProgramClient, ProgramRpcClient, ProgramRpcClientSendTransaction}, + std::{process::exit, rc::Rc, sync::Arc}, +}; + +pub type Error = Box; + +pub fn println_display(config: &Config, message: String) { + match config.output_format { + OutputFormat::Display | OutputFormat::DisplayVerbose => { + println!("{}", message); + } + _ => {} + } +} + +pub fn eprintln_display(config: &Config, message: String) { + match config.output_format { + OutputFormat::Display | OutputFormat::DisplayVerbose => { + eprintln!("{}", message); + } + _ => {} + } +} + +pub struct Config { + pub rpc_client: Arc, + pub program_client: Arc>, + pub default_signer: Option>, + pub fee_payer: Option>, + pub output_format: OutputFormat, + pub dry_run: bool, +} +impl Config { + pub fn new( + cli: Cli, + matches: ArgMatches, + wallet_manager: &mut Option>, + ) -> Self { + // get the generic cli config struct + let cli_config = if let Some(config_file) = &cli.config_file { + solana_cli_config::Config::load(config_file).unwrap_or_else(|_| { + eprintln!("error: Could not load config file `{}`", config_file); + exit(1); + }) + } else if let Some(config_file) = &*solana_cli_config::CONFIG_FILE { + solana_cli_config::Config::load(config_file).unwrap_or_default() + } else { + solana_cli_config::Config::default() + }; + + // create rpc client + let rpc_client = Arc::new(RpcClient::new_with_commitment( + cli.json_rpc_url.unwrap_or(cli_config.json_rpc_url), + CommitmentConfig::confirmed(), + )); + + // and program client + let program_client = Arc::new(ProgramRpcClient::new( + rpc_client.clone(), + ProgramRpcClientSendTransaction, + )); + + // resolve default signer + let default_keypair = cli_config.keypair_path; + let default_signer = + signer_from_path(&matches, &default_keypair, "default", wallet_manager) + .ok() + .map(Arc::from); + + // resolve fee-payer + let fee_payer_arg = + with_signer(&matches, wallet_manager, cli.fee_payer, "fee_payer").unwrap(); + let fee_payer = default_signer + .clone() + .map(|default_signer| signer_from_arg(fee_payer_arg, &default_signer).unwrap()); + + // determine output format + let output_format = match (cli.output_format, cli.verbose) { + (Some(json_format), _) => json_format, + (None, true) => OutputFormat::DisplayVerbose, + (None, false) => OutputFormat::Display, + }; + + Self { + rpc_client, + program_client, + default_signer, + fee_payer, + output_format, + dry_run: cli.dry_run, + } + } + + // Returns Ok(default signer), or Err if there is no default signer configured + pub fn default_signer(&self) -> Result, Error> { + if let Some(default_signer) = &self.default_signer { + Ok(default_signer.clone()) + } else { + Err("default signer is required, please specify a valid default signer by identifying a \ + valid configuration file using the --config argument, or by creating a valid config \ + at the default location of ~/.config/solana/cli/config.yml using the solana config \ + command".to_string().into()) + } + } + + // Returns Ok(fee payer), or Err if there is no fee payer configured + pub fn fee_payer(&self) -> Result, Error> { + if let Some(fee_payer) = &self.fee_payer { + Ok(fee_payer.clone()) + } else { + Err("fee payer is required, please specify a valid fee payer using the --payer argument, or \ + by identifying a valid configuration file using the --config argument, or by creating a \ + valid config at the default location of ~/.config/solana/cli/config.yml using the solana \ + config command".to_string().into()) + } + } + + pub fn verbose(&self) -> bool { + self.output_format == OutputFormat::DisplayVerbose + } +} diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs new file mode 100644 index 00000000..b469edc3 --- /dev/null +++ b/clients/cli/src/main.rs @@ -0,0 +1,876 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(deprecated)] + +use { + clap::{CommandFactory, Parser}, + solana_clap_v3_utils::input_parsers::Amount, + solana_client::{ + rpc_config::RpcProgramAccountsConfig, + rpc_filter::{Memcmp, RpcFilterType}, + }, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + pubkey::Pubkey, + signature::{Keypair, Signature, Signer}, + stake, + transaction::Transaction, + }, + solana_vote_program::{self as vote_program, vote_state::VoteState}, + spl_single_pool::{ + self, find_default_deposit_account_address, find_pool_address, find_pool_mint_address, + find_pool_stake_address, instruction::SinglePoolInstruction, state::SinglePool, + }, + spl_token_client::token::Token, +}; + +mod config; +use config::*; + +mod cli; +use cli::*; + +mod output; +use output::*; + +mod quarantine; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let cli = Cli::parse(); + let matches = Cli::command().get_matches(); + let mut wallet_manager = None; + + let command = cli + .command + .clone() + .with_signers(&matches, &mut wallet_manager)?; + let config = Config::new(cli, matches, &mut wallet_manager); + + solana_logger::setup_with_default("solana=info"); + + let res = command.execute(&config).await?; + println!("{}", res); + + Ok(()) +} + +pub type CommandResult = Result; + +impl Command { + pub async fn execute(self, config: &Config) -> CommandResult { + match self { + Command::Manage(command) => match command.manage { + ManageCommand::Initialize(command_config) => { + command_initialize(config, command_config).await + } + ManageCommand::ReactivatePoolStake(command_config) => { + command_reactivate_pool_stake(config, command_config).await + } + ManageCommand::CreateTokenMetadata(command_config) => { + command_create_metadata(config, command_config).await + } + ManageCommand::UpdateTokenMetadata(command_config) => { + command_update_metadata(config, command_config).await + } + }, + Command::Deposit(command_config) => command_deposit(config, command_config).await, + Command::Withdraw(command_config) => command_withdraw(config, command_config).await, + Command::CreateDefaultStake(command_config) => { + command_create_stake(config, command_config).await + } + Command::Display(command_config) => command_display(config, command_config).await, + } + } +} + +// initialize a new stake pool for a vote account +async fn command_initialize(config: &Config, command_config: InitializeCli) -> CommandResult { + let payer = config.fee_payer()?; + let vote_account_address = command_config.vote_account_address; + + println_display( + config, + format!( + "Initializing single-validator stake pool for vote account {}\n", + vote_account_address, + ), + ); + + // check if the vote account is valid + let vote_account = config + .program_client + .get_account(vote_account_address) + .await?; + if vote_account.is_none() || vote_account.unwrap().owner != vote_program::id() { + return Err(format!("{} is not a valid vote account", vote_account_address,).into()); + } + + let pool_address = find_pool_address(&spl_single_pool::id(), &vote_account_address); + + // check if the pool has already been initialized + if config + .program_client + .get_account(pool_address) + .await? + .is_some() + { + return Err(format!( + "Pool {} for vote account {} already exists", + pool_address, vote_account_address + ) + .into()); + } + + let mut instructions = spl_single_pool::instruction::initialize( + &spl_single_pool::id(), + &vote_account_address, + &payer.pubkey(), + &quarantine::get_rent(config).await?, + quarantine::get_minimum_delegation(config).await?, + ); + + // get rid of the CreateMetadata instruction if desired, eg if mpl breaks compat + if command_config.skip_metadata { + assert_eq!( + instructions.last().unwrap().data, + borsh::to_vec(&SinglePoolInstruction::CreateTokenMetadata).unwrap() + ); + + instructions.pop(); + } + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &vec![payer], + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + Ok(format_output( + config, + "Initialize".to_string(), + StakePoolOutput { + pool_address, + vote_account_address, + available_stake: 0, + token_supply: 0, + signature, + }, + )) +} + +// reactivate pool stake account +async fn command_reactivate_pool_stake( + config: &Config, + command_config: ReactivateCli, +) -> CommandResult { + let payer = config.fee_payer()?; + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + println_display( + config, + format!("Reactivating stake account for pool {}\n", pool_address), + ); + + let vote_account_address = + if let Some(pool_data) = config.program_client.get_account(pool_address).await? { + try_from_slice_unchecked::(&pool_data.data)?.vote_account_address + } else { + return Err(format!("Pool {} has not been initialized", pool_address).into()); + }; + + // the only reason this check is skippable is for testing, otherwise theres no + // reason + if !command_config.skip_deactivation_check { + let current_epoch = config.rpc_client.get_epoch_info().await?.epoch; + let pool_stake_address = find_pool_stake_address(&spl_single_pool::id(), &pool_address); + let pool_stake_deactivated = quarantine::get_stake_info(config, &pool_stake_address) + .await? + .unwrap() + .1 + .delegation + .deactivation_epoch + <= current_epoch; + + if !pool_stake_deactivated { + return Err("Pool stake account is neither deactivating nor deactivated".into()); + } + } + + let instruction = spl_single_pool::instruction::reactivate_pool_stake( + &spl_single_pool::id(), + &vote_account_address, + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &vec![payer], + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + Ok(format_output( + config, + "ReactivatePoolStake".to_string(), + SignatureOutput { signature }, + )) +} + +// deposit stake +async fn command_deposit(config: &Config, command_config: DepositCli) -> CommandResult { + let payer = config.fee_payer()?; + let owner = config.default_signer()?; + let stake_authority = signer_from_arg(command_config.stake_withdraw_authority, &owner)?; + let lamport_recipient = command_config + .lamport_recipient_address + .unwrap_or_else(|| owner.pubkey()); + + let current_epoch = config.rpc_client.get_epoch_info().await?.epoch; + + // the cli invocation for this is conceptually simple, but a bit tricky + // the user can provide pool or vote and let the cli infer the stake account + // address but they can also provide pool or vote with the stake account, as + // a safety check first we want to get the pool address if they provided a + // pool or vote address + let provided_pool_address = command_config.pool_address.or_else(|| { + command_config + .vote_account_address + .map(|address| find_pool_address(&spl_single_pool::id(), &address)) + }); + + // from there we can determine the stake account address + let stake_account_address = + if let Some(stake_account_address) = command_config.stake_account_address { + stake_account_address + } else if let Some(pool_address) = provided_pool_address { + assert!(command_config.default_stake_account); + find_default_deposit_account_address(&pool_address, &stake_authority.pubkey()) + } else { + unreachable!() + }; + + // now we validate the stake account and definitively resolve the pool address + let (pool_address, user_stake_active) = if let Some((meta, stake)) = + quarantine::get_stake_info(config, &stake_account_address).await? + { + let derived_pool_address = + find_pool_address(&spl_single_pool::id(), &stake.delegation.voter_pubkey); + + if let Some(provided_pool_address) = provided_pool_address { + if provided_pool_address != derived_pool_address { + return Err(format!( + "Provided pool address {} does not match stake account-derived address {}", + provided_pool_address, derived_pool_address, + ) + .into()); + } + } + + if meta.authorized.withdrawer != stake_authority.pubkey() { + return Err(format!( + "Incorrect withdraw authority for stake account {}: got {}, expected {}", + stake_account_address, + meta.authorized.withdrawer, + stake_authority.pubkey(), + ) + .into()); + } + + if stake.delegation.deactivation_epoch < u64::MAX { + return Err(format!( + "Stake account {} is deactivating or deactivated", + stake_account_address + ) + .into()); + } + + ( + derived_pool_address, + stake.delegation.activation_epoch <= current_epoch, + ) + } else { + return Err(format!("Could not find stake account {}", stake_account_address).into()); + }; + + println_display( + config, + format!( + "Depositing stake from account {} into pool {}\n", + stake_account_address, pool_address + ), + ); + + if config + .program_client + .get_account(pool_address) + .await? + .is_none() + { + return Err(format!("Pool {} has not been initialized", pool_address).into()); + } + + let pool_stake_address = find_pool_stake_address(&spl_single_pool::id(), &pool_address); + let pool_stake_active = quarantine::get_stake_info(config, &pool_stake_address) + .await? + .unwrap() + .1 + .delegation + .activation_epoch + <= current_epoch; + + if user_stake_active != pool_stake_active { + return Err("Activation status mismatch; try again next epoch".into()); + } + + let pool_mint_address = find_pool_mint_address(&spl_single_pool::id(), &pool_address); + let token = Token::new( + config.program_client.clone(), + &spl_token::id(), + &pool_mint_address, + None, + payer.clone(), + ); + + // use token account provided, or get/create the associated account for the + // client keypair + let token_account_address = if let Some(account) = command_config.token_account_address { + account + } else { + token + .get_or_create_associated_account_info(&owner.pubkey()) + .await?; + token.get_associated_token_address(&owner.pubkey()) + }; + + let previous_token_amount = token + .get_account_info(&token_account_address) + .await? + .base + .amount; + + let instructions = spl_single_pool::instruction::deposit( + &spl_single_pool::id(), + &pool_address, + &stake_account_address, + &token_account_address, + &lamport_recipient, + &stake_authority.pubkey(), + ); + + let mut signers = vec![]; + for signer in [payer.clone(), stake_authority] { + if !signers.contains(&signer) { + signers.push(signer); + } + } + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + let token_amount = token + .get_account_info(&token_account_address) + .await? + .base + .amount + - previous_token_amount; + + Ok(format_output( + config, + "Deposit".to_string(), + DepositOutput { + pool_address, + token_amount, + signature, + }, + )) +} + +// withdraw stake +async fn command_withdraw(config: &Config, command_config: WithdrawCli) -> CommandResult { + let payer = config.fee_payer()?; + let owner = config.default_signer()?; + let token_authority = signer_from_arg(command_config.token_authority, &owner)?; + let stake_authority_address = command_config + .stake_authority_address + .unwrap_or_else(|| owner.pubkey()); + + let stake_account = Keypair::new(); + let stake_account_address = stake_account.pubkey(); + + // since we can't infer pool from token account, the withdraw invocation is + // rather simpler first get the pool address + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + if config + .program_client + .get_account(pool_address) + .await? + .is_none() + { + return Err(format!("Pool {} has not been initialized", pool_address).into()); + } + + // now all the mint and token info + let pool_mint_address = find_pool_mint_address(&spl_single_pool::id(), &pool_address); + let token = Token::new( + config.program_client.clone(), + &spl_token::id(), + &pool_mint_address, + None, + payer.clone(), + ); + + let token_account_address = command_config + .token_account_address + .unwrap_or_else(|| token.get_associated_token_address(&owner.pubkey())); + + let token_account = token.get_account_info(&token_account_address).await?; + + let token_amount = match command_config.token_amount.sol_to_lamport() { + Amount::All => token_account.base.amount, + Amount::Raw(amount) => amount, + Amount::Decimal(_) => unreachable!(), + }; + + println_display( + config, + format!( + "Withdrawing from pool {} into new stake account {}; burning {} tokens from {}\n", + pool_address, stake_account_address, token_amount, token_account_address, + ), + ); + + if token_amount == 0 { + return Err("Cannot withdraw zero tokens".into()); + } + + if token_amount > token_account.base.amount { + return Err(format!( + "Withdraw amount {} exceeds tokens in account ({})", + token_amount, token_account.base.amount + ) + .into()); + } + + // note a delegate authority is not allowed here because we must authorize the + // pool authority + if token_account.base.owner != token_authority.pubkey() { + return Err(format!( + "Invalid token authority: got {}, actual {}", + token_account.base.owner, + token_authority.pubkey() + ) + .into()); + } + + // create a blank stake account to withdraw into + let mut instructions = vec![ + quarantine::create_uninitialized_stake_account_instruction( + config, + &payer.pubkey(), + &stake_account_address, + ) + .await?, + ]; + + // perform the withdrawal + instructions.extend(spl_single_pool::instruction::withdraw( + &spl_single_pool::id(), + &pool_address, + &stake_account_address, + &stake_authority_address, + &token_account_address, + &token_authority.pubkey(), + token_amount, + )); + + // possibly deactivate the new stake account + if command_config.deactivate { + instructions.push(stake::instruction::deactivate_stake( + &stake_account_address, + &stake_authority_address, + )); + } + + let mut signers = vec![]; + for signer in [payer.as_ref(), token_authority.as_ref(), &stake_account] { + if !signers.contains(&signer) { + signers.push(signer); + } + } + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &signers, + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + let stake_amount = if let Some((_, stake)) = + quarantine::get_stake_info(config, &stake_account_address).await? + { + stake.delegation.stake + } else { + 0 + }; + + Ok(format_output( + config, + "Withdraw".to_string(), + WithdrawOutput { + pool_address, + stake_account_address, + stake_amount, + signature, + }, + )) +} + +// create token metadata +async fn command_create_metadata( + config: &Config, + command_config: CreateMetadataCli, +) -> CommandResult { + let payer = config.fee_payer()?; + + // first get the pool address + // i dont check metadata because i dont want to get entangled with mpl + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + println_display( + config, + format!( + "Creating default token metadata for pool {}\n", + pool_address + ), + ); + + if config + .program_client + .get_account(pool_address) + .await? + .is_none() + { + return Err(format!("Pool {} has not been initialized", pool_address).into()); + } + + // and... i guess thats it? + + let instruction = spl_single_pool::instruction::create_token_metadata( + &spl_single_pool::id(), + &pool_address, + &payer.pubkey(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &vec![payer], + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + Ok(format_output( + config, + "CreateTokenMetadata".to_string(), + SignatureOutput { signature }, + )) +} + +// update token metadata +async fn command_update_metadata( + config: &Config, + command_config: UpdateMetadataCli, +) -> CommandResult { + let payer = config.fee_payer()?; + let owner = config.default_signer()?; + let authorized_withdrawer = signer_from_arg(command_config.authorized_withdrawer, &owner)?; + + // first get the pool address + // i dont check metadata because i dont want to get entangled with mpl + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + println_display( + config, + format!("Updating token metadata for pool {}\n", pool_address), + ); + + // we always need the vote account + let vote_account_address = + if let Some(pool_data) = config.program_client.get_account(pool_address).await? { + try_from_slice_unchecked::(&pool_data.data)?.vote_account_address + } else { + return Err(format!("Pool {} has not been initialized", pool_address).into()); + }; + + if let Some(vote_account_data) = config + .program_client + .get_account(vote_account_address) + .await? + { + let vote_account = VoteState::deserialize(&vote_account_data.data)?; + + if authorized_withdrawer.pubkey() != vote_account.authorized_withdrawer { + return Err(format!( + "Invalid authorized withdrawer: got {}, actual {}", + authorized_withdrawer.pubkey(), + vote_account.authorized_withdrawer, + ) + .into()); + } + } else { + // we know the pool exists so the vote account must exist + unreachable!(); + } + + let instruction = spl_single_pool::instruction::update_token_metadata( + &spl_single_pool::id(), + &vote_account_address, + &authorized_withdrawer.pubkey(), + command_config.token_name, + command_config.token_symbol, + command_config.token_uri.unwrap_or_default(), + ); + + let mut signers = vec![]; + for signer in [payer.clone(), authorized_withdrawer] { + if !signers.contains(&signer) { + signers.push(signer); + } + } + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &signers, + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + Ok(format_output( + config, + "UpdateTokenMetadata".to_string(), + SignatureOutput { signature }, + )) +} + +// create default stake account +async fn command_create_stake(config: &Config, command_config: CreateStakeCli) -> CommandResult { + let payer = config.fee_payer()?; + let owner = config.default_signer()?; + let stake_authority_address = command_config + .stake_authority_address + .unwrap_or_else(|| owner.pubkey()); + + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + println_display( + config, + format!("Creating default stake account for pool {}\n", pool_address), + ); + + let vote_account_address = + if let Some(vote_account_address) = command_config.vote_account_address { + vote_account_address + } else if let Some(pool_data) = config.program_client.get_account(pool_address).await? { + try_from_slice_unchecked::(&pool_data.data)?.vote_account_address + } else { + return Err(format!( + "Cannot determine vote account address from uninitialized pool {}", + pool_address, + ) + .into()); + }; + + if command_config.vote_account_address.is_some() + && config + .program_client + .get_account(pool_address) + .await? + .is_none() + { + eprintln_display( + config, + format!("warning: Pool {} has not been initialized", pool_address), + ); + } + + let instructions = spl_single_pool::instruction::create_and_delegate_user_stake( + &spl_single_pool::id(), + &vote_account_address, + &stake_authority_address, + &quarantine::get_rent(config).await?, + command_config.lamports, + ); + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &vec![payer], + config.program_client.get_latest_blockhash().await?, + ); + + let signature = process_transaction(config, transaction).await?; + + Ok(format_output( + config, + "CreateDefaultStake".to_string(), + CreateStakeOutput { + pool_address, + stake_account_address: find_default_deposit_account_address( + &pool_address, + &stake_authority_address, + ), + signature, + }, + )) +} + +// display stake pool(s) +async fn command_display(config: &Config, command_config: DisplayCli) -> CommandResult { + if command_config.all { + // the filter isn't necessary now but makes the cli forward-compatible + let pools = config + .rpc_client + .get_program_accounts_with_config( + &spl_single_pool::id(), + RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + 0, + vec![1], + ))]), + ..RpcProgramAccountsConfig::default() + }, + ) + .await?; + + let mut displays = vec![]; + for pool in pools { + let vote_account_address = + try_from_slice_unchecked::(&pool.1.data)?.vote_account_address; + displays.push(get_pool_display(config, pool.0, Some(vote_account_address)).await?); + } + + Ok(format_output( + config, + "DisplayAll".to_string(), + StakePoolListOutput(displays), + )) + } else { + let pool_address = pool_address_from_args( + command_config.pool_address, + command_config.vote_account_address, + ); + + Ok(format_output( + config, + "Display".to_string(), + get_pool_display(config, pool_address, None).await?, + )) + } +} + +async fn get_pool_display( + config: &Config, + pool_address: Pubkey, + maybe_vote_account: Option, +) -> Result { + let vote_account_address = if let Some(address) = maybe_vote_account { + address + } else if let Some(pool_data) = config.program_client.get_account(pool_address).await? { + if let Ok(data) = try_from_slice_unchecked::(&pool_data.data) { + data.vote_account_address + } else { + return Err(format!( + "Failed to parse account at {}; is this a pool?", + pool_address + ) + .into()); + } + } else { + return Err(format!("Pool {} does not exist", pool_address).into()); + }; + + let pool_stake_address = find_pool_stake_address(&spl_single_pool::id(), &pool_address); + let available_stake = + if let Some((_, stake)) = quarantine::get_stake_info(config, &pool_stake_address).await? { + stake.delegation.stake - quarantine::get_minimum_delegation(config).await? + } else { + unreachable!() + }; + + let pool_mint_address = find_pool_mint_address(&spl_single_pool::id(), &pool_address); + let token_supply = config + .rpc_client + .get_token_supply(&pool_mint_address) + .await? + .amount + .parse::()?; + + Ok(StakePoolOutput { + pool_address, + vote_account_address, + available_stake, + token_supply, + signature: None, + }) +} + +async fn process_transaction( + config: &Config, + transaction: Transaction, +) -> Result, Error> { + if config.dry_run { + let simulation_data = config.rpc_client.simulate_transaction(&transaction).await?; + + if config.verbose() { + if let Some(logs) = simulation_data.value.logs { + for log in logs { + println!(" {}", log); + } + } + + println!( + "\nSimulation succeeded, consumed {} compute units", + simulation_data.value.units_consumed.unwrap() + ); + } else { + println_display(config, "Simulation succeeded".to_string()); + } + + Ok(None) + } else { + Ok(Some( + config + .rpc_client + .send_and_confirm_transaction_with_spinner(&transaction) + .await?, + )) + } +} diff --git a/clients/cli/src/output.rs b/clients/cli/src/output.rs new file mode 100644 index 00000000..06098654 --- /dev/null +++ b/clients/cli/src/output.rs @@ -0,0 +1,307 @@ +use { + crate::config::Config, + console::style, + serde::{Deserialize, Serialize}, + serde_with::{serde_as, DisplayFromStr}, + solana_cli_output::{display::writeln_name_value, QuietDisplay, VerboseDisplay}, + solana_sdk::{pubkey::Pubkey, signature::Signature}, + spl_single_pool::{ + self, find_pool_mint_address, find_pool_mint_authority_address, + find_pool_mpl_authority_address, find_pool_stake_address, + find_pool_stake_authority_address, + }, + std::fmt::{Display, Formatter, Result, Write}, +}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CommandOutput +where + T: Serialize + Display + QuietDisplay + VerboseDisplay, +{ + pub(crate) command_name: String, + pub(crate) command_output: T, +} + +impl Display for CommandOutput +where + T: Serialize + Display + QuietDisplay + VerboseDisplay, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.command_output, f) + } +} + +impl QuietDisplay for CommandOutput +where + T: Serialize + Display + QuietDisplay + VerboseDisplay, +{ + fn write_str(&self, w: &mut dyn std::fmt::Write) -> std::fmt::Result { + QuietDisplay::write_str(&self.command_output, w) + } +} + +impl VerboseDisplay for CommandOutput +where + T: Serialize + Display + QuietDisplay + VerboseDisplay, +{ + fn write_str(&self, w: &mut dyn std::fmt::Write) -> std::fmt::Result { + writeln_name_value(w, "Command:", &self.command_name)?; + VerboseDisplay::write_str(&self.command_output, w) + } +} + +pub fn format_output(config: &Config, command_name: String, command_output: T) -> String +where + T: Serialize + Display + QuietDisplay + VerboseDisplay, +{ + config.output_format.formatted_string(&CommandOutput { + command_name, + command_output, + }) +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignatureOutput { + #[serde_as(as = "Option")] + pub signature: Option, +} + +impl QuietDisplay for SignatureOutput {} +impl VerboseDisplay for SignatureOutput {} + +impl Display for SignatureOutput { + fn fmt(&self, f: &mut Formatter) -> Result { + writeln!(f)?; + + if let Some(signature) = self.signature { + writeln_name_value(f, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StakePoolOutput { + #[serde_as(as = "DisplayFromStr")] + pub pool_address: Pubkey, + #[serde_as(as = "DisplayFromStr")] + pub vote_account_address: Pubkey, + pub available_stake: u64, + pub token_supply: u64, + #[serde_as(as = "Option")] + pub signature: Option, +} + +impl QuietDisplay for StakePoolOutput {} +impl VerboseDisplay for StakePoolOutput { + fn write_str(&self, w: &mut dyn Write) -> Result { + writeln!(w)?; + writeln!(w, "{}", style("SPL Single-Validator Stake Pool").bold())?; + writeln_name_value(w, " Pool address:", &self.pool_address.to_string())?; + writeln_name_value( + w, + " Vote account address:", + &self.vote_account_address.to_string(), + )?; + + writeln_name_value( + w, + " Pool stake address:", + &find_pool_stake_address(&spl_single_pool::id(), &self.pool_address).to_string(), + )?; + writeln_name_value( + w, + " Pool mint address:", + &find_pool_mint_address(&spl_single_pool::id(), &self.pool_address).to_string(), + )?; + writeln_name_value( + w, + " Pool stake authority address:", + &find_pool_stake_authority_address(&spl_single_pool::id(), &self.pool_address) + .to_string(), + )?; + writeln_name_value( + w, + " Pool mint authority address:", + &find_pool_mint_authority_address(&spl_single_pool::id(), &self.pool_address) + .to_string(), + )?; + writeln_name_value( + w, + " Pool MPL authority address:", + &find_pool_mpl_authority_address(&spl_single_pool::id(), &self.pool_address) + .to_string(), + )?; + + writeln_name_value(w, " Available stake:", &self.available_stake.to_string())?; + writeln_name_value(w, " Token supply:", &self.token_supply.to_string())?; + + if let Some(signature) = self.signature { + writeln!(w)?; + writeln_name_value(w, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} + +impl Display for StakePoolOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f)?; + writeln!(f, "{}", style("SPL Single-Validator Stake Pool").bold())?; + writeln_name_value(f, " Pool address:", &self.pool_address.to_string())?; + writeln_name_value( + f, + " Vote account address:", + &self.vote_account_address.to_string(), + )?; + writeln_name_value(f, " Available stake:", &self.available_stake.to_string())?; + writeln_name_value(f, " Token supply:", &self.token_supply.to_string())?; + + if let Some(signature) = self.signature { + writeln!(f)?; + writeln_name_value(f, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct StakePoolListOutput(pub Vec); + +impl QuietDisplay for StakePoolListOutput {} +impl VerboseDisplay for StakePoolListOutput { + fn write_str(&self, w: &mut dyn Write) -> Result { + let mut stake = 0; + for svsp in &self.0 { + VerboseDisplay::write_str(svsp, w)?; + stake += svsp.available_stake; + } + + writeln!(w)?; + writeln_name_value(w, "Total stake:", &stake.to_string())?; + + Ok(()) + } +} + +impl Display for StakePoolListOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let mut stake = 0; + for svsp in &self.0 { + svsp.fmt(f)?; + stake += svsp.available_stake; + } + + writeln!(f)?; + writeln_name_value(f, "Total stake:", &stake.to_string())?; + + Ok(()) + } +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DepositOutput { + #[serde_as(as = "DisplayFromStr")] + pub pool_address: Pubkey, + pub token_amount: u64, + #[serde_as(as = "Option")] + pub signature: Option, +} + +impl QuietDisplay for DepositOutput {} +impl VerboseDisplay for DepositOutput {} + +impl Display for DepositOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f)?; + writeln_name_value(f, "Pool address:", &self.pool_address.to_string())?; + writeln_name_value(f, "Token amount:", &self.token_amount.to_string())?; + + if let Some(signature) = self.signature { + writeln!(f)?; + writeln_name_value(f, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WithdrawOutput { + #[serde_as(as = "DisplayFromStr")] + pub pool_address: Pubkey, + #[serde_as(as = "DisplayFromStr")] + pub stake_account_address: Pubkey, + pub stake_amount: u64, + #[serde_as(as = "Option")] + pub signature: Option, +} + +impl QuietDisplay for WithdrawOutput {} +impl VerboseDisplay for WithdrawOutput {} + +impl Display for WithdrawOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f)?; + writeln_name_value(f, "Pool address:", &self.pool_address.to_string())?; + writeln_name_value( + f, + "Stake account address:", + &self.stake_account_address.to_string(), + )?; + writeln_name_value(f, "Stake amount:", &self.stake_amount.to_string())?; + + if let Some(signature) = self.signature { + writeln!(f)?; + writeln_name_value(f, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} + +#[serde_as] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateStakeOutput { + #[serde_as(as = "DisplayFromStr")] + pub pool_address: Pubkey, + #[serde_as(as = "DisplayFromStr")] + pub stake_account_address: Pubkey, + #[serde_as(as = "Option")] + pub signature: Option, +} + +impl QuietDisplay for CreateStakeOutput {} +impl VerboseDisplay for CreateStakeOutput {} + +impl Display for CreateStakeOutput { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + writeln!(f)?; + writeln_name_value(f, "Pool address:", &self.pool_address.to_string())?; + writeln_name_value( + f, + "Stake account address:", + &self.stake_account_address.to_string(), + )?; + + if let Some(signature) = self.signature { + writeln!(f)?; + writeln_name_value(f, "Signature:", &signature.to_string())?; + } + + Ok(()) + } +} diff --git a/clients/cli/src/quarantine.rs b/clients/cli/src/quarantine.rs new file mode 100644 index 00000000..530f865b --- /dev/null +++ b/clients/cli/src/quarantine.rs @@ -0,0 +1,78 @@ +// XXX this file will be deleted and replaced with a stake program client once i +// write one + +use { + crate::config::*, + solana_sdk::{ + instruction::Instruction, + native_token::LAMPORTS_PER_SOL, + pubkey::Pubkey, + stake::{ + self, + state::{Meta, Stake, StakeStateV2}, + }, + system_instruction, + sysvar::{self, rent::Rent}, + }, +}; + +pub async fn get_rent(config: &Config) -> Result { + let rent_data = config + .program_client + .get_account(sysvar::rent::id()) + .await? + .unwrap(); + let rent = bincode::deserialize::(&rent_data.data)?; + + Ok(rent) +} + +pub async fn get_minimum_delegation(config: &Config) -> Result { + Ok(std::cmp::max( + config.rpc_client.get_stake_minimum_delegation().await?, + LAMPORTS_PER_SOL, + )) +} + +pub async fn get_stake_info( + config: &Config, + stake_account_address: &Pubkey, +) -> Result, Error> { + if let Some(stake_account) = config + .program_client + .get_account(*stake_account_address) + .await? + { + match bincode::deserialize::(&stake_account.data)? { + StakeStateV2::Stake(meta, stake, _) => Ok(Some((meta, stake))), + StakeStateV2::Initialized(_) => { + Err(format!("Stake account {} is undelegated", stake_account_address).into()) + } + StakeStateV2::Uninitialized => { + Err(format!("Stake account {} is uninitialized", stake_account_address).into()) + } + StakeStateV2::RewardsPool => unimplemented!(), + } + } else { + Ok(None) + } +} + +pub async fn create_uninitialized_stake_account_instruction( + config: &Config, + payer: &Pubkey, + stake_account: &Pubkey, +) -> Result { + let rent_amount = config + .program_client + .get_minimum_balance_for_rent_exemption(std::mem::size_of::()) + .await?; + + Ok(system_instruction::create_account( + payer, + stake_account, + rent_amount, + std::mem::size_of::() as u64, + &stake::program::id(), + )) +} diff --git a/clients/cli/tests/test.rs b/clients/cli/tests/test.rs new file mode 100644 index 00000000..bceafc59 --- /dev/null +++ b/clients/cli/tests/test.rs @@ -0,0 +1,429 @@ +#![allow(clippy::arithmetic_side_effects)] + +use { + serial_test::serial, + solana_cli_config::Config as SolanaConfig, + solana_client::nonblocking::rpc_client::RpcClient, + solana_sdk::{ + bpf_loader_upgradeable, + clock::Epoch, + epoch_schedule::{EpochSchedule, MINIMUM_SLOTS_PER_EPOCH}, + native_token::LAMPORTS_PER_SOL, + pubkey::Pubkey, + signature::{write_keypair_file, Keypair, Signer}, + stake::{ + self, + state::{Authorized, Lockup, StakeStateV2}, + }, + system_instruction, system_program, + transaction::Transaction, + }, + solana_test_validator::{TestValidator, TestValidatorGenesis, UpgradeableProgramInfo}, + solana_vote_program::{ + vote_instruction::{self, CreateVoteAccountConfig}, + vote_state::{VoteInit, VoteState}, + }, + spl_token_client::client::{ProgramClient, ProgramRpcClient, ProgramRpcClientSendTransaction}, + std::{path::PathBuf, process::Command, str::FromStr, sync::Arc, time::Duration}, + tempfile::NamedTempFile, + test_case::test_case, + tokio::time::sleep, +}; + +type PClient = Arc>; +const SVSP_CLI: &str = "../../target/debug/spl-single-pool"; + +#[allow(dead_code)] +pub struct Env { + pub rpc_client: Arc, + pub program_client: PClient, + pub payer: Keypair, + pub keypair_file_path: String, + pub config_file_path: String, + pub vote_account: Pubkey, + + // persist in struct so they dont scope out but callers dont need to make them + validator: TestValidator, + keypair_file: NamedTempFile, + config_file: NamedTempFile, +} + +async fn setup(initialize: bool) -> Env { + // start test validator + let (validator, payer) = start_validator().await; + + // make clients + let rpc_client = Arc::new(validator.get_async_rpc_client()); + let program_client: PClient = Arc::new(ProgramRpcClient::new( + rpc_client.clone(), + ProgramRpcClientSendTransaction, + )); + + // write the payer to disk + let keypair_file = NamedTempFile::new().unwrap(); + write_keypair_file(&payer, &keypair_file).unwrap(); + + // write a full config file with our rpc and payer to disk + let config_file = NamedTempFile::new().unwrap(); + let config_file_path = config_file.path().to_str().unwrap(); + let solana_config = SolanaConfig { + json_rpc_url: validator.rpc_url(), + websocket_url: validator.rpc_pubsub_url(), + keypair_path: keypair_file.path().to_str().unwrap().to_string(), + ..SolanaConfig::default() + }; + solana_config.save(config_file_path).unwrap(); + + // make vote and stake accounts + let vote_account = create_vote_account(&program_client, &payer, &payer.pubkey()).await; + if initialize { + let status = Command::new(SVSP_CLI) + .args([ + "manage", + "initialize", + "-C", + config_file_path, + &vote_account.to_string(), + ]) + .status() + .unwrap(); + assert!(status.success()); + } + + Env { + rpc_client, + program_client, + payer, + keypair_file_path: keypair_file.path().to_str().unwrap().to_string(), + config_file_path: config_file_path.to_string(), + vote_account, + validator, + keypair_file, + config_file, + } +} + +async fn start_validator() -> (TestValidator, Keypair) { + solana_logger::setup(); + let mut test_validator_genesis = TestValidatorGenesis::default(); + + test_validator_genesis.epoch_schedule(EpochSchedule::custom( + MINIMUM_SLOTS_PER_EPOCH, + MINIMUM_SLOTS_PER_EPOCH, + false, + )); + + test_validator_genesis.add_upgradeable_programs_with_path(&[ + UpgradeableProgramInfo { + program_id: Pubkey::from_str("metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s").unwrap(), + loader: bpf_loader_upgradeable::id(), + program_path: PathBuf::from("../program/tests/fixtures/mpl_token_metadata.so"), + upgrade_authority: Pubkey::default(), + }, + UpgradeableProgramInfo { + program_id: spl_single_pool::id(), + loader: bpf_loader_upgradeable::id(), + program_path: PathBuf::from("../../target/deploy/spl_single_pool.so"), + upgrade_authority: Pubkey::default(), + }, + ]); + test_validator_genesis.start_async().await +} + +async fn wait_for_next_epoch(rpc_client: &RpcClient) -> Epoch { + let current_epoch = rpc_client.get_epoch_info().await.unwrap().epoch; + println!("current epoch {}, advancing to next...", current_epoch); + loop { + let epoch_info = rpc_client.get_epoch_info().await.unwrap(); + if epoch_info.epoch > current_epoch { + return epoch_info.epoch; + } + + sleep(Duration::from_millis(200)).await; + } +} + +async fn create_vote_account( + program_client: &PClient, + payer: &Keypair, + withdrawer: &Pubkey, +) -> Pubkey { + let validator = Keypair::new(); + let vote_account = Keypair::new(); + let voter = Keypair::new(); + + let zero_rent = program_client + .get_minimum_balance_for_rent_exemption(0) + .await + .unwrap(); + + let vote_rent = program_client + .get_minimum_balance_for_rent_exemption(VoteState::size_of() * 2) + .await + .unwrap(); + + let blockhash = program_client.get_latest_blockhash().await.unwrap(); + + let mut instructions = vec![system_instruction::create_account( + &payer.pubkey(), + &validator.pubkey(), + zero_rent, + 0, + &system_program::id(), + )]; + instructions.append(&mut vote_instruction::create_account_with_config( + &payer.pubkey(), + &vote_account.pubkey(), + &VoteInit { + node_pubkey: validator.pubkey(), + authorized_voter: voter.pubkey(), + authorized_withdrawer: *withdrawer, + ..VoteInit::default() + }, + vote_rent, + CreateVoteAccountConfig { + space: VoteState::size_of() as u64, + ..Default::default() + }, + )); + + let mut transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey())); + + transaction + .try_partial_sign(&vec![payer], blockhash) + .unwrap(); + transaction + .try_partial_sign(&vec![&validator, &vote_account], blockhash) + .unwrap(); + + program_client.send_transaction(&transaction).await.unwrap(); + + vote_account.pubkey() +} + +async fn create_and_delegate_stake_account( + program_client: &PClient, + payer: &Keypair, + vote_account: &Pubkey, +) -> Pubkey { + let stake_account = Keypair::new(); + + let stake_rent = program_client + .get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()) + .await + .unwrap(); + let blockhash = program_client.get_latest_blockhash().await.unwrap(); + + let mut transaction = Transaction::new_with_payer( + &stake::instruction::create_account( + &payer.pubkey(), + &stake_account.pubkey(), + &Authorized::auto(&payer.pubkey()), + &Lockup::default(), + stake_rent + LAMPORTS_PER_SOL, + ), + Some(&payer.pubkey()), + ); + + transaction + .try_partial_sign(&vec![payer], blockhash) + .unwrap(); + transaction + .try_partial_sign(&vec![&stake_account], blockhash) + .unwrap(); + + program_client.send_transaction(&transaction).await.unwrap(); + + let mut transaction = Transaction::new_with_payer( + &[stake::instruction::delegate_stake( + &stake_account.pubkey(), + &payer.pubkey(), + vote_account, + )], + Some(&payer.pubkey()), + ); + + transaction.sign(&vec![payer], blockhash); + + program_client.send_transaction(&transaction).await.unwrap(); + + stake_account.pubkey() +} + +#[tokio::test] +#[serial] +async fn reactivate_pool_stake() { + let env = setup(true).await; + + // setting up a test validator for this to succeed is hell, and success is + // tested in program tests so we just make sure the cli can send a + // well-formed instruction + let output = Command::new(SVSP_CLI) + .args([ + "manage", + "reactivate-pool-stake", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + "--skip-deactivation-check", + ]) + .output() + .unwrap(); + assert!(String::from_utf8(output.stderr) + .unwrap() + .contains("custom program error: 0xc")); +} + +#[test_case(true; "default_stake")] +#[test_case(false; "normal_stake")] +#[tokio::test] +#[serial] +async fn deposit(use_default: bool) { + let env = setup(true).await; + + let stake_account = if use_default { + let status = Command::new(SVSP_CLI) + .args([ + "create-default-stake", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + &LAMPORTS_PER_SOL.to_string(), + ]) + .status() + .unwrap(); + assert!(status.success()); + + Pubkey::default() + } else { + create_and_delegate_stake_account(&env.program_client, &env.payer, &env.vote_account).await + }; + + wait_for_next_epoch(&env.rpc_client).await; + + let mut args = vec![ + "deposit".to_string(), + "-C".to_string(), + env.config_file_path, + ]; + + if use_default { + args.extend([ + "--vote-account".to_string(), + env.vote_account.to_string(), + "--default-stake-account".to_string(), + ]); + } else { + args.push(stake_account.to_string()); + }; + + let status = Command::new(SVSP_CLI).args(&args).status().unwrap(); + assert!(status.success()); +} + +#[tokio::test] +#[serial] +async fn withdraw() { + let env = setup(true).await; + let stake_account = + create_and_delegate_stake_account(&env.program_client, &env.payer, &env.vote_account).await; + + wait_for_next_epoch(&env.rpc_client).await; + + let status = Command::new(SVSP_CLI) + .args([ + "deposit", + "-C", + &env.config_file_path, + &stake_account.to_string(), + ]) + .status() + .unwrap(); + assert!(status.success()); + + let status = Command::new(SVSP_CLI) + .args([ + "withdraw", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + "ALL", + ]) + .status() + .unwrap(); + assert!(status.success()); +} + +#[tokio::test] +#[serial] +async fn create_metadata() { + let env = setup(false).await; + + let status = Command::new(SVSP_CLI) + .args([ + "manage", + "initialize", + "-C", + &env.config_file_path, + "--skip-metadata", + &env.vote_account.to_string(), + ]) + .status() + .unwrap(); + assert!(status.success()); + + let status = Command::new(SVSP_CLI) + .args([ + "manage", + "create-token-metadata", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + ]) + .status() + .unwrap(); + assert!(status.success()); +} + +#[tokio::test] +#[serial] +async fn update_metadata() { + let env = setup(true).await; + + let status = Command::new(SVSP_CLI) + .args([ + "manage", + "update-token-metadata", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + "whatever", + "idk", + ]) + .status() + .unwrap(); + assert!(status.success()); + + // testing this flag because the match is rather torturous + let status = Command::new(SVSP_CLI) + .args([ + "manage", + "update-token-metadata", + "-C", + &env.config_file_path, + "--vote-account", + &env.vote_account.to_string(), + "--authorized-withdrawer", + &env.keypair_file_path, + "something", + "new", + ]) + .status() + .unwrap(); + assert!(status.success()); +} 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.cjs b/clients/js-legacy/.eslintrc.cjs new file mode 100644 index 00000000..63db7b84 --- /dev/null +++ b/clients/js-legacy/.eslintrc.cjs @@ -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..77738287 --- /dev/null +++ b/clients/js-legacy/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/clients/js-legacy/.prettierrc.cjs b/clients/js-legacy/.prettierrc.cjs new file mode 100644 index 00000000..8446d684 --- /dev/null +++ b/clients/js-legacy/.prettierrc.cjs @@ -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..f5416d3e --- /dev/null +++ b/clients/js-legacy/README.md @@ -0,0 +1,11 @@ +# `@solana/spl-single-pool-classic` + +A TypeScript library for interacting with the SPL Single-Validator Stake Pool program, targeting `@solana/web3.js` 1.x. +**If you are working on the new, bleeding-edge web3.js, you want `@solana/spl-single-pool`.** + +For information on installation and usage, see [SPL docs](https://spl.solana.com/single-pool). + +For support, please ask questions on the [Solana Stack Exchange](https://solana.stackexchange.com). + +If you've found a bug or you'd like to request a feature, please +[open an issue](https://github.com/solana-labs/solana-program-library/issues/new). diff --git a/clients/js-legacy/package.json b/clients/js-legacy/package.json new file mode 100644 index 00000000..2d56325b --- /dev/null +++ b/clients/js-legacy/package.json @@ -0,0 +1,43 @@ +{ + "name": "@solana/spl-single-pool-classic", + "version": "1.0.2", + "main": "dist/cjs/index.js", + "module": "dist/mjs/index.js", + "exports": { + ".": { + "import": "./dist/mjs/index.js", + "require": "./dist/cjs/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist/*", + "build": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./ts-fixup.sh", + "build:program": "cargo build-sbf --manifest-path=../../../program/Cargo.toml", + "lint": "eslint --max-warnings 0 .", + "lint:fix": "eslint . --fix", + "test": "sed -i '1s/.*/{ \"type\": \"module\",/' package.json && NODE_OPTIONS='--loader=tsx' ava ; ret=$?; sed -i '1s/.*/{/' package.json && exit $ret" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@ava/typescript": "^5.0.0", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "ava": "^6.2.0", + "eslint": "^8.57.0", + "solana-bankrun": "^0.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "dependencies": { + "@solana/web3.js": "^1.95.5", + "@solana/addresses": "2.0.0", + "@solana/spl-single-pool": "1.0.0" + }, + "ava": { + "extensions": { + "ts": "module" + }, + "nodeArguments": [ + "--import=tsx" + ] + } +} diff --git a/clients/js-legacy/src/addresses.ts b/clients/js-legacy/src/addresses.ts new file mode 100644 index 00000000..e9b6a22b --- /dev/null +++ b/clients/js-legacy/src/addresses.ts @@ -0,0 +1,72 @@ +import type { Address } from '@solana/addresses'; +import { PublicKey } from '@solana/web3.js'; +import type { PoolAddress, VoteAccountAddress } from '@solana/spl-single-pool'; +import { + findPoolAddress as findPoolModern, + findPoolStakeAddress as findStakeModern, + findPoolMintAddress as findMintModern, + findPoolStakeAuthorityAddress as findStakeAuthorityModern, + findPoolMintAuthorityAddress as findMintAuthorityModern, + findPoolMplAuthorityAddress as findMplAuthorityModern, + findDefaultDepositAccountAddress as findDefaultDepositModern, +} from '@solana/spl-single-pool'; + +export async function findPoolAddress(programId: PublicKey, voteAccountAddress: PublicKey) { + return new PublicKey( + await findPoolModern( + programId.toBase58() as Address, + voteAccountAddress.toBase58() as VoteAccountAddress, + ), + ); +} + +export async function findPoolStakeAddress(programId: PublicKey, poolAddress: PublicKey) { + return new PublicKey( + await findStakeModern(programId.toBase58() as Address, poolAddress.toBase58() as PoolAddress), + ); +} + +export async function findPoolMintAddress(programId: PublicKey, poolAddress: PublicKey) { + return new PublicKey( + await findMintModern(programId.toBase58() as Address, poolAddress.toBase58() as PoolAddress), + ); +} + +export async function findPoolStakeAuthorityAddress(programId: PublicKey, poolAddress: PublicKey) { + return new PublicKey( + await findStakeAuthorityModern( + programId.toBase58() as Address, + poolAddress.toBase58() as PoolAddress, + ), + ); +} + +export async function findPoolMintAuthorityAddress(programId: PublicKey, poolAddress: PublicKey) { + return new PublicKey( + await findMintAuthorityModern( + programId.toBase58() as Address, + poolAddress.toBase58() as PoolAddress, + ), + ); +} + +export async function findPoolMplAuthorityAddress(programId: PublicKey, poolAddress: PublicKey) { + return new PublicKey( + await findMplAuthorityModern( + programId.toBase58() as Address, + poolAddress.toBase58() as PoolAddress, + ), + ); +} + +export async function findDefaultDepositAccountAddress( + poolAddress: PublicKey, + userWallet: PublicKey, +) { + return new PublicKey( + await findDefaultDepositModern( + poolAddress.toBase58() as PoolAddress, + userWallet.toBase58() as Address, + ), + ); +} diff --git a/clients/js-legacy/src/index.ts b/clients/js-legacy/src/index.ts new file mode 100644 index 00000000..dc21826a --- /dev/null +++ b/clients/js-legacy/src/index.ts @@ -0,0 +1,19 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import { getVoteAccountAddressForPool as getVoteModern } from '@solana/spl-single-pool'; +import type { PoolAddress } from '@solana/spl-single-pool'; + +import { rpc } from './internal.js'; + +export * from './mpl_metadata.js'; +export * from './addresses.js'; +export * from './instructions.js'; +export * from './transactions.js'; + +export async function getVoteAccountAddressForPool(connection: Connection, poolAddress: PublicKey) { + const voteAccountModern = await getVoteModern( + rpc(connection), + poolAddress.toBase58() as PoolAddress, + ); + + return new PublicKey(voteAccountModern); +} diff --git a/clients/js-legacy/src/instructions.ts b/clients/js-legacy/src/instructions.ts new file mode 100644 index 00000000..ae24d338 --- /dev/null +++ b/clients/js-legacy/src/instructions.ts @@ -0,0 +1,82 @@ +import type { Address } from '@solana/addresses'; +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import type { PoolAddress, VoteAccountAddress } from '@solana/spl-single-pool'; +import { SinglePoolInstruction as PoolInstructionModern } from '@solana/spl-single-pool'; + +import { modernInstructionToLegacy } from './internal.js'; + +export class SinglePoolInstruction { + static async initializePool(voteAccount: PublicKey): Promise { + const instruction = await PoolInstructionModern.initializePool( + voteAccount.toBase58() as VoteAccountAddress, + ); + return modernInstructionToLegacy(instruction); + } + + static async reactivatePoolStake(voteAccount: PublicKey): Promise { + const instruction = await PoolInstructionModern.reactivatePoolStake( + voteAccount.toBase58() as VoteAccountAddress, + ); + return modernInstructionToLegacy(instruction); + } + + static async depositStake( + pool: PublicKey, + userStakeAccount: PublicKey, + userTokenAccount: PublicKey, + userLamportAccount: PublicKey, + ): Promise { + const instruction = await PoolInstructionModern.depositStake( + pool.toBase58() as PoolAddress, + userStakeAccount.toBase58() as Address, + userTokenAccount.toBase58() as Address, + userLamportAccount.toBase58() as Address, + ); + return modernInstructionToLegacy(instruction); + } + + static async withdrawStake( + pool: PublicKey, + userStakeAccount: PublicKey, + userStakeAuthority: PublicKey, + userTokenAccount: PublicKey, + tokenAmount: number | bigint, + ): Promise { + const instruction = await PoolInstructionModern.withdrawStake( + pool.toBase58() as PoolAddress, + userStakeAccount.toBase58() as Address, + userStakeAuthority.toBase58() as Address, + userTokenAccount.toBase58() as Address, + BigInt(tokenAmount), + ); + return modernInstructionToLegacy(instruction); + } + + static async createTokenMetadata( + pool: PublicKey, + payer: PublicKey, + ): Promise { + const instruction = await PoolInstructionModern.createTokenMetadata( + pool.toBase58() as PoolAddress, + payer.toBase58() as Address, + ); + return modernInstructionToLegacy(instruction); + } + + static async updateTokenMetadata( + voteAccount: PublicKey, + authorizedWithdrawer: PublicKey, + tokenName: string, + tokenSymbol: string, + tokenUri?: string, + ): Promise { + const instruction = await PoolInstructionModern.updateTokenMetadata( + voteAccount.toBase58() as VoteAccountAddress, + authorizedWithdrawer.toBase58() as Address, + tokenName, + tokenSymbol, + tokenUri, + ); + return modernInstructionToLegacy(instruction); + } +} diff --git a/clients/js-legacy/src/internal.ts b/clients/js-legacy/src/internal.ts new file mode 100644 index 00000000..e1628ee7 --- /dev/null +++ b/clients/js-legacy/src/internal.ts @@ -0,0 +1,71 @@ +import { Connection, Transaction, TransactionInstruction, PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; + +export function rpc(connection: Connection) { + return { + getAccountInfo(address: string) { + return { + async send() { + const pubkey = new PublicKey(address); + return await connection.getAccountInfo(pubkey); + }, + }; + }, + getMinimumBalanceForRentExemption(size: bigint) { + return { + async send() { + return BigInt(await connection.getMinimumBalanceForRentExemption(Number(size))); + }, + }; + }, + getStakeMinimumDelegation() { + return { + async send() { + const minimumDelegation = await connection.getStakeMinimumDelegation(); + return { value: BigInt(minimumDelegation.value) }; + }, + }; + }, + }; +} + +export function modernInstructionToLegacy(modernInstruction: any): TransactionInstruction { + const keys = []; + for (const account of modernInstruction.accounts) { + keys.push({ + pubkey: new PublicKey(account.address), + isSigner: !!(account.role & 2), + isWritable: !!(account.role & 1), + }); + } + + return new TransactionInstruction({ + programId: new PublicKey(modernInstruction.programAddress), + keys, + data: Buffer.from(modernInstruction.data), + }); +} + +export function modernTransactionToLegacy(modernTransaction: any): Transaction { + const legacyTransaction = new Transaction(); + legacyTransaction.add(...modernTransaction.instructions.map(modernInstructionToLegacy)); + + return legacyTransaction; +} + +export function paramsToModern(params: any) { + const modernParams = {} as any; + for (const k of Object.keys(params)) { + if (k == 'connection') { + modernParams.rpc = rpc(params[k]); + } else if (params[k] instanceof PublicKey || params[k].constructor.name == 'PublicKey') { + modernParams[k] = params[k].toBase58(); + } else if (typeof params[k] == 'number') { + modernParams[k] = BigInt(params[k]); + } else { + modernParams[k] = params[k]; + } + } + + return modernParams; +} diff --git a/clients/js-legacy/src/mpl_metadata.ts b/clients/js-legacy/src/mpl_metadata.ts new file mode 100644 index 00000000..31c52ae2 --- /dev/null +++ b/clients/js-legacy/src/mpl_metadata.ts @@ -0,0 +1,12 @@ +import { PublicKey } from '@solana/web3.js'; +import { Buffer } from 'buffer'; + +export const MPL_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); + +export function findMplMetadataAddress(poolMintAddress: PublicKey) { + const [publicKey] = PublicKey.findProgramAddressSync( + [Buffer.from('metadata'), MPL_METADATA_PROGRAM_ID.toBuffer(), poolMintAddress.toBuffer()], + MPL_METADATA_PROGRAM_ID, + ); + return publicKey; +} diff --git a/clients/js-legacy/src/transactions.ts b/clients/js-legacy/src/transactions.ts new file mode 100644 index 00000000..aac785ab --- /dev/null +++ b/clients/js-legacy/src/transactions.ts @@ -0,0 +1,115 @@ +import type { Address } from '@solana/addresses'; +import { PublicKey, Connection } from '@solana/web3.js'; +import type { PoolAddress, VoteAccountAddress } from '@solana/spl-single-pool'; +import { SinglePoolProgram as PoolProgramModern } from '@solana/spl-single-pool'; + +import { paramsToModern, modernTransactionToLegacy, rpc } from './internal.js'; + +interface DepositParams { + connection: Connection; + pool: PublicKey; + userWallet: PublicKey; + userStakeAccount?: PublicKey; + depositFromDefaultAccount?: boolean; + userTokenAccount?: PublicKey; + userLamportAccount?: PublicKey; + userWithdrawAuthority?: PublicKey; +} + +interface WithdrawParams { + connection: Connection; + pool: PublicKey; + userWallet: PublicKey; + userStakeAccount: PublicKey; + tokenAmount: number | bigint; + createStakeAccount?: boolean; + userStakeAuthority?: PublicKey; + userTokenAccount?: PublicKey; + userTokenAuthority?: PublicKey; +} + +export class SinglePoolProgram { + static programId: PublicKey = new PublicKey(PoolProgramModern.programAddress); + static space: number = Number(PoolProgramModern.space); + + static async initialize( + connection: Connection, + voteAccount: PublicKey, + payer: PublicKey, + skipMetadata = false, + ) { + const modernTransaction = await PoolProgramModern.initialize( + rpc(connection), + voteAccount.toBase58() as VoteAccountAddress, + payer.toBase58() as Address, + skipMetadata, + ); + + return modernTransactionToLegacy(modernTransaction); + } + + static async reactivatePoolStake(connection: Connection, voteAccount: PublicKey) { + const modernTransaction = await PoolProgramModern.reactivatePoolStake( + voteAccount.toBase58() as VoteAccountAddress, + ); + + return modernTransactionToLegacy(modernTransaction); + } + + static async deposit(params: DepositParams) { + const modernParams = paramsToModern(params); + const modernTransaction = await PoolProgramModern.deposit(modernParams); + + return modernTransactionToLegacy(modernTransaction); + } + + static async withdraw(params: WithdrawParams) { + const modernParams = paramsToModern(params); + const modernTransaction = await PoolProgramModern.withdraw(modernParams); + + return modernTransactionToLegacy(modernTransaction); + } + + static async createTokenMetadata(pool: PublicKey, payer: PublicKey) { + const modernTransaction = await PoolProgramModern.createTokenMetadata( + pool.toBase58() as PoolAddress, + payer.toBase58() as Address, + ); + + return modernTransactionToLegacy(modernTransaction); + } + + static async updateTokenMetadata( + voteAccount: PublicKey, + authorizedWithdrawer: PublicKey, + name: string, + symbol: string, + uri?: string, + ) { + const modernTransaction = await PoolProgramModern.updateTokenMetadata( + voteAccount.toBase58() as VoteAccountAddress, + authorizedWithdrawer.toBase58() as Address, + name, + symbol, + uri, + ); + + return modernTransactionToLegacy(modernTransaction); + } + + static async createAndDelegateUserStake( + connection: Connection, + voteAccount: PublicKey, + userWallet: PublicKey, + stakeAmount: number | bigint, + ) { + const modernTransaction = await PoolProgramModern.createAndDelegateUserStake( + rpc(connection), + voteAccount.toBase58() as VoteAccountAddress, + userWallet.toBase58() as Address, + BigInt(stakeAmount), + ); + + return modernTransactionToLegacy(modernTransaction); + } +} diff --git a/clients/js-legacy/tests/fixtures/mpl_token_metadata.so b/clients/js-legacy/tests/fixtures/mpl_token_metadata.so new file mode 120000 index 00000000..df4d3516 --- /dev/null +++ b/clients/js-legacy/tests/fixtures/mpl_token_metadata.so @@ -0,0 +1 @@ +../../../../../../stake-pool/program/tests/fixtures/mpl_token_metadata.so \ No newline at end of file diff --git a/clients/js-legacy/tests/fixtures/spl_single_pool.so b/clients/js-legacy/tests/fixtures/spl_single_pool.so new file mode 120000 index 00000000..5fe6f8bd --- /dev/null +++ b/clients/js-legacy/tests/fixtures/spl_single_pool.so @@ -0,0 +1 @@ +../../../../../../target/deploy/spl_single_pool.so \ No newline at end of file diff --git a/clients/js-legacy/tests/transactions.test.ts b/clients/js-legacy/tests/transactions.test.ts new file mode 100644 index 00000000..38dcc41d --- /dev/null +++ b/clients/js-legacy/tests/transactions.test.ts @@ -0,0 +1,432 @@ +import test from 'ava'; +import { start, BanksClient, ProgramTestContext } from 'solana-bankrun'; +import { + Keypair, + PublicKey, + Transaction, + Authorized, + TransactionInstruction, + StakeProgram, + VoteProgram, +} from '@solana/web3.js'; +import { Buffer } from 'buffer'; +import { + getVoteAccountAddressForPool, + findDefaultDepositAccountAddress, + MPL_METADATA_PROGRAM_ID, + findPoolAddress, + findPoolStakeAddress, + findPoolMintAddress, + SinglePoolProgram, + findMplMetadataAddress, +} from '../src/index.ts'; +import * as voteAccount from './vote_account.json'; + +const SLOTS_PER_EPOCH: bigint = 432000n; + +class BanksConnection { + constructor(client: BanksClient, payer: Keypair) { + this.client = client; + this.payer = payer; + } + + async getMinimumBalanceForRentExemption(dataLen: number): Promise { + const rent = await this.client.getRent(); + return Number(rent.minimumBalance(BigInt(dataLen))); + } + + async getStakeMinimumDelegation() { + const transaction = new Transaction(); + transaction.add( + new TransactionInstruction({ + programId: StakeProgram.programId, + keys: [], + data: Buffer.from([13, 0, 0, 0]), + }), + ); + transaction.recentBlockhash = (await this.client.getLatestBlockhash())[0]; + transaction.feePayer = this.payer.publicKey; + transaction.sign(this.payer); + + const res = await this.client.simulateTransaction(transaction); + const data = Array.from(res.inner.meta.returnData.data); + const minimumDelegation = data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24); + + return { value: minimumDelegation }; + } + + async getAccountInfo(address: PublicKey, commitment?: string): Promise> { + const account = await this.client.getAccount(address, commitment); + if (account) { + account.data = Buffer.from(account.data); + } + return account; + } +} + +async function startWithContext(authorizedWithdrawer?: PublicKey) { + const voteAccountData = Uint8Array.from(atob(voteAccount.account.data[0]), (c) => + c.charCodeAt(0), + ); + + if (authorizedWithdrawer != null) { + voteAccountData.set(authorizedWithdrawer.toBytes(), 36); + } + + return await start( + [ + { name: 'spl_single_pool', programId: SinglePoolProgram.programId }, + { name: 'mpl_token_metadata', programId: MPL_METADATA_PROGRAM_ID }, + ], + [ + { + address: new PublicKey(voteAccount.pubkey), + info: { + lamports: voteAccount.account.lamports, + data: voteAccountData, + owner: VoteProgram.programId, + executable: false, + }, + }, + ], + ); +} + +async function processTransaction( + context: ProgramTestContext, + transaction: Transaction, + signers = [], +) { + transaction.recentBlockhash = context.lastBlockhash; + transaction.feePayer = context.payer.publicKey; + transaction.sign(...[context.payer].concat(signers)); + return context.banksClient.processTransaction(transaction); +} + +async function createAndDelegateStakeAccount( + context: ProgramTestContext, + voteAccountAddress: PublicKey, +): Promise { + const connection = new BanksConnection(context.banksClient, context.payer); + let userStakeAccount = new Keypair(); + + const stakeRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); + const minimumDelegation = (await connection.getStakeMinimumDelegation()).value; + let transaction = StakeProgram.createAccount({ + authorized: new Authorized(context.payer.publicKey, context.payer.publicKey), + fromPubkey: context.payer.publicKey, + lamports: stakeRent + minimumDelegation, + stakePubkey: userStakeAccount.publicKey, + }); + await processTransaction(context, transaction, [userStakeAccount]); + userStakeAccount = userStakeAccount.publicKey; + + transaction = StakeProgram.delegate({ + authorizedPubkey: context.payer.publicKey, + stakePubkey: userStakeAccount, + votePubkey: voteAccountAddress, + }); + await processTransaction(context, transaction); + + return userStakeAccount; +} + +test('initialize', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + + // initialize pool + const transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + t.truthy(await client.getAccount(poolAddress), 'pool has been created'); + t.truthy( + await client.getAccount( + findMplMetadataAddress(await findPoolMintAddress(SinglePoolProgram.programId, poolAddress)), + ), + 'metadata has been created', + ); +}); + +test('reactivate pool stake', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + + // initialize pool + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + const slot = await client.getSlot(); + context.warpToSlot(slot + SLOTS_PER_EPOCH); + + // reactivate pool stake + transaction = await SinglePoolProgram.reactivatePoolStake(connection, voteAccountAddress); + + // setting up the validator state for this to succeed is very annoying + // we test success in program tests; here we just confirm we submit a well-formed transaction + let message = ''; + try { + await processTransaction(context, transaction); + } catch (e) { + message = e.message; + } finally { + t.true(message.includes('custom program error: 0xc'), 'got expected stake mismatch error'); + } +}); + +test('deposit', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + const poolStakeAddress = await findPoolStakeAddress(SinglePoolProgram.programId, poolAddress); + const userStakeAccount = await createAndDelegateStakeAccount(context, voteAccountAddress); + + // initialize pool + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + const slot = await client.getSlot(); + context.warpToSlot(slot + SLOTS_PER_EPOCH); + + // deposit + transaction = await SinglePoolProgram.deposit({ + connection, + pool: poolAddress, + userWallet: payer.publicKey, + userStakeAccount, + }); + await processTransaction(context, transaction); + + const stakeRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); + const minimumDelegation = (await connection.getStakeMinimumDelegation()).value; + const poolStakeAccount = await client.getAccount(poolStakeAddress); + t.is(poolStakeAccount.lamports, minimumDelegation * 2 + stakeRent, 'stake has been deposited'); +}); + +test('deposit from default', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + const poolStakeAddress = await findPoolStakeAddress(SinglePoolProgram.programId, poolAddress); + + // create default account + const minimumDelegation = (await connection.getStakeMinimumDelegation()).value; + let transaction = await SinglePoolProgram.createAndDelegateUserStake( + connection, + voteAccountAddress, + payer.publicKey, + minimumDelegation, + ); + await processTransaction(context, transaction); + + // initialize pool + transaction = await SinglePoolProgram.initialize(connection, voteAccountAddress, payer.publicKey); + await processTransaction(context, transaction); + + const slot = await client.getSlot(); + context.warpToSlot(slot + SLOTS_PER_EPOCH); + + // deposit + transaction = await SinglePoolProgram.deposit({ + connection, + pool: poolAddress, + userWallet: payer.publicKey, + depositFromDefaultAccount: true, + }); + await processTransaction(context, transaction); + + const stakeRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); + const poolStakeAccount = await client.getAccount(poolStakeAddress); + t.is(poolStakeAccount.lamports, minimumDelegation * 2 + stakeRent, 'stake has been deposited'); +}); + +test('withdraw', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + const poolStakeAddress = await findPoolStakeAddress(SinglePoolProgram.programId, poolAddress); + const depositAccount = await createAndDelegateStakeAccount(context, voteAccountAddress); + + // initialize pool + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + const slot = await client.getSlot(); + context.warpToSlot(slot + SLOTS_PER_EPOCH); + + // deposit + transaction = await SinglePoolProgram.deposit({ + connection, + pool: poolAddress, + userWallet: payer.publicKey, + userStakeAccount: depositAccount, + }); + await processTransaction(context, transaction); + + const minimumDelegation = (await connection.getStakeMinimumDelegation()).value; + const poolStakeAccount = await client.getAccount(poolStakeAddress); + t.true(poolStakeAccount.lamports > minimumDelegation * 2, 'stake has been deposited'); + + // withdraw + const withdrawAccount = new Keypair(); + transaction = await SinglePoolProgram.withdraw({ + connection, + pool: poolAddress, + userWallet: payer.publicKey, + userStakeAccount: withdrawAccount.publicKey, + tokenAmount: minimumDelegation, + createStakeAccount: true, + }); + await processTransaction(context, transaction, [withdrawAccount]); + + const stakeRent = await connection.getMinimumBalanceForRentExemption(StakeProgram.space); + const userStakeAccount = await client.getAccount(withdrawAccount.publicKey); + t.is(userStakeAccount.lamports, minimumDelegation + stakeRent, 'stake has been withdrawn'); +}); + +test('create metadata', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + + // initialize pool without metadata + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + true, + ); + await processTransaction(context, transaction); + + t.truthy(await client.getAccount(poolAddress), 'pool has been created'); + t.falsy( + await client.getAccount( + findMplMetadataAddress(await findPoolMintAddress(SinglePoolProgram.programId, poolAddress)), + ), + 'metadata has not been created', + ); + + // create metadata + transaction = await SinglePoolProgram.createTokenMetadata(poolAddress, payer.publicKey); + await processTransaction(context, transaction); + + t.truthy( + await client.getAccount( + findMplMetadataAddress(await findPoolMintAddress(SinglePoolProgram.programId, poolAddress)), + ), + 'metadata has been created', + ); +}); + +test('update metadata', async (t) => { + const authorizedWithdrawer = new Keypair(); + + const context = await startWithContext(authorizedWithdrawer.publicKey); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + const poolMintAddress = await findPoolMintAddress(SinglePoolProgram.programId, poolAddress); + const poolMetadataAddress = findMplMetadataAddress(poolMintAddress); + + // initialize pool + let transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + // update metadata + const newName = 'hana wuz here'; + transaction = await SinglePoolProgram.updateTokenMetadata( + voteAccountAddress, + authorizedWithdrawer.publicKey, + newName, + '', + ); + await processTransaction(context, transaction, [authorizedWithdrawer]); + + const metadataAccount = await client.getAccount(poolMetadataAddress); + t.true( + new TextDecoder('ascii').decode(metadataAccount.data).indexOf(newName) > -1, + 'metadata name has been updated', + ); +}); + +test('get vote account address', async (t) => { + const context = await startWithContext(); + const client = context.banksClient; + const payer = context.payer; + const connection = new BanksConnection(client, payer); + + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const poolAddress = await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress); + + // initialize pool + const transaction = await SinglePoolProgram.initialize( + connection, + voteAccountAddress, + payer.publicKey, + ); + await processTransaction(context, transaction); + + const chainVoteAccount = await getVoteAccountAddressForPool(connection, poolAddress); + t.true(chainVoteAccount.equals(voteAccountAddress), 'got correct vote account'); +}); + +test('default account address', async (t) => { + const voteAccountAddress = new PublicKey(voteAccount.pubkey); + const owner = new PublicKey('GtaYCtXWCrciizttN5mx9P38niTQPGWpfu6DnSgAr3Cj'); + const expectedDefault = new PublicKey('BbfrNeJrd82cSFsULXT9zG8SvLLB8WsTc1gQsDFy3Sed'); + + const actualDefault = await findDefaultDepositAccountAddress( + await findPoolAddress(SinglePoolProgram.programId, voteAccountAddress), + owner, + ); + + t.true(actualDefault.equals(expectedDefault), 'got correct default account address'); +}); diff --git a/clients/js-legacy/tests/vote_account.json b/clients/js-legacy/tests/vote_account.json new file mode 100644 index 00000000..44a5efd1 --- /dev/null +++ b/clients/js-legacy/tests/vote_account.json @@ -0,0 +1,14 @@ +{ + "pubkey": "KRAKEnMdmT4EfM8ykTFH6yLoCd5vNLcQvJwF66Y2dag", + "account": { + "lamports": 1300578700922, + "data": [ + "AQAAAAs8CYpjxAGc9BKIFsvo43erJeAPq9FLBOZuVf7zcXQwDtalO9ClDHolg+JcQCSa0sIFkdUQpQh5ufXK07iakuhkHwAAAAAAAAACxGIMAAAAAB8AAAADxGIMAAAAAB4AAAAExGIMAAAAAB0AAAAFxGIMAAAAABwAAAAGxGIMAAAAABsAAAAHxGIMAAAAABoAAAAIxGIMAAAAABkAAAAJxGIMAAAAABgAAAAKxGIMAAAAABcAAAALxGIMAAAAABYAAAAMxGIMAAAAABUAAAANxGIMAAAAABQAAAAOxGIMAAAAABMAAAAPxGIMAAAAABIAAAAQxGIMAAAAABEAAAARxGIMAAAAABAAAAASxGIMAAAAAA8AAAATxGIMAAAAAA4AAAAUxGIMAAAAAA0AAAAVxGIMAAAAAAwAAAAWxGIMAAAAAAsAAAAXxGIMAAAAAAoAAAAYxGIMAAAAAAkAAAAZxGIMAAAAAAgAAAAaxGIMAAAAAAcAAAAbxGIMAAAAAAYAAAAcxGIMAAAAAAUAAAAdxGIMAAAAAAQAAAAexGIMAAAAAAMAAAAfxGIMAAAAAAIAAAAgxGIMAAAAAAEAAAABAcRiDAAAAAABAAAAAAAAAOEBAAAAAAAACzwJimPEAZz0EogWy+jjd6sl4A+r0UsE5m5V/vNxdDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfAAAAAAAAAAFAAAAAAAAAAKIBAAAAAAAA1diYBAAAAAAvw5IEAAAAAKMBAAAAAAAA++WeBAAAAADV2JgEAAAAAKQBAAAAAAAA5dukBAAAAAD75Z4EAAAAAKUBAAAAAAAAbf6qBAAAAADl26QEAAAAAKYBAAAAAAAA1hGxBAAAAABt/qoEAAAAAKcBAAAAAAAAaiS3BAAAAADWEbEEAAAAAKgBAAAAAAAARHK9BAAAAABqJLcEAAAAAKkBAAAAAAAAacPDBAAAAABEcr0EAAAAAKoBAAAAAAAAWQ3KBAAAAABpw8MEAAAAAKsBAAAAAAAAUHLQBAAAAABZDcoEAAAAAKwBAAAAAAAAk9nWBAAAAABQctAEAAAAAK0BAAAAAAAAxTHdBAAAAACT2dYEAAAAAK4BAAAAAAAA34bjBAAAAADFMd0EAAAAAK8BAAAAAAAA0+vpBAAAAADfhuMEAAAAALABAAAAAAAAnFLwBAAAAADT6+kEAAAAALEBAAAAAAAAt7z2BAAAAACcUvAEAAAAALIBAAAAAAAAoyT9BAAAAAC3vPYEAAAAALMBAAAAAAAAXX0DBQAAAACjJP0EAAAAALQBAAAAAAAA6NcJBQAAAABdfQMFAAAAALUBAAAAAAAA5wQQBQAAAADo1wkFAAAAALYBAAAAAAAAvAMWBQAAAADnBBAFAAAAALcBAAAAAAAA6DkcBQAAAAC8AxYFAAAAALgBAAAAAAAAx34iBQAAAADoORwFAAAAALkBAAAAAAAAm80oBQAAAADHfiIFAAAAALoBAAAAAAAAriQvBQAAAACbzSgFAAAAALsBAAAAAAAAsHE1BQAAAACuJC8FAAAAALwBAAAAAAAADpM7BQAAAACwcTUFAAAAAL0BAAAAAAAANsdBBQAAAAAOkzsFAAAAAL4BAAAAAAAAXgNIBQAAAAA2x0EFAAAAAL8BAAAAAAAAnBJOBQAAAABeA0gFAAAAAMABAAAAAAAAukpUBQAAAACcEk4FAAAAAMEBAAAAAAAALIxaBQAAAAC6SlQFAAAAAMIBAAAAAAAAzddgBQAAAAAsjFoFAAAAAMMBAAAAAAAAaS9nBQAAAADN12AFAAAAAMQBAAAAAAAATG1tBQAAAABpL2cFAAAAAMUBAAAAAAAAqptzBQAAAABMbW0FAAAAAMYBAAAAAAAACvJ5BQAAAACqm3MFAAAAAMcBAAAAAAAARUmABQAAAAAK8nkFAAAAAMgBAAAAAAAATJGGBQAAAABFSYAFAAAAAMkBAAAAAAAAZ+CMBQAAAABMkYYFAAAAAMoBAAAAAAAAsyGTBQAAAABn4IwFAAAAAMsBAAAAAAAAT2GZBQAAAACzIZMFAAAAAMwBAAAAAAAAEHKfBQAAAABPYZkFAAAAAM0BAAAAAAAAzbClBQAAAAAQcp8FAAAAAM4BAAAAAAAA0gWsBQAAAADNsKUFAAAAAM8BAAAAAAAAP2eyBQAAAADSBawFAAAAANABAAAAAAAAOLu4BQAAAAA/Z7IFAAAAANEBAAAAAAAAVQC/BQAAAAA4u7gFAAAAANIBAAAAAAAAilLFBQAAAABVAL8FAAAAANMBAAAAAAAAfaLLBQAAAACKUsUFAAAAANQBAAAAAAAAGfrRBQAAAAB9ossFAAAAANUBAAAAAAAA/1DYBQAAAAAZ+tEFAAAAANYBAAAAAAAA06reBQAAAAD/UNgFAAAAANcBAAAAAAAAwwXlBQAAAADTqt4FAAAAANgBAAAAAAAAnVvrBQAAAADDBeUFAAAAANkBAAAAAAAAvbXxBQAAAACdW+sFAAAAANoBAAAAAAAAMQH4BQAAAAC9tfEFAAAAANsBAAAAAAAANT/+BQAAAAAxAfgFAAAAANwBAAAAAAAA04wEBgAAAAA1P/4FAAAAAN0BAAAAAAAAhNIKBgAAAADTjAQGAAAAAN4BAAAAAAAADCkRBgAAAACE0goGAAAAAN8BAAAAAAAAL4MXBgAAAAAMKREGAAAAAOABAAAAAAAAF9odBgAAAAAvgxcGAAAAAOEBAAAAAAAAjfQdBgAAAAAX2h0GAAAAACDEYgwAAAAAzUXCZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "base64" + ], + "owner": "Vote111111111111111111111111111111111111111", + "executable": false, + "rentEpoch": 361, + "space": 3731 + } +} \ No newline at end of file diff --git a/clients/js-legacy/ts-fixup.sh b/clients/js-legacy/ts-fixup.sh new file mode 120000 index 00000000..2b79c262 --- /dev/null +++ b/clients/js-legacy/ts-fixup.sh @@ -0,0 +1 @@ +../../ts-fixup.sh \ No newline at end of file diff --git a/clients/js-legacy/tsconfig-base.json b/clients/js-legacy/tsconfig-base.json new file mode 120000 index 00000000..8cdeff68 --- /dev/null +++ b/clients/js-legacy/tsconfig-base.json @@ -0,0 +1 @@ +../../tsconfig-base.json \ No newline at end of file diff --git a/clients/js-legacy/tsconfig-cjs.json b/clients/js-legacy/tsconfig-cjs.json new file mode 120000 index 00000000..eb5b6778 --- /dev/null +++ b/clients/js-legacy/tsconfig-cjs.json @@ -0,0 +1 @@ +../../tsconfig-cjs.json \ No newline at end of file diff --git a/clients/js-legacy/tsconfig.json b/clients/js-legacy/tsconfig.json new file mode 120000 index 00000000..fd0e4743 --- /dev/null +++ b/clients/js-legacy/tsconfig.json @@ -0,0 +1 @@ +../../tsconfig.json \ No newline at end of file diff --git a/clients/js/.eslintignore b/clients/js/.eslintignore new file mode 100644 index 00000000..58542507 --- /dev/null +++ b/clients/js/.eslintignore @@ -0,0 +1,4 @@ +dist +node_modules +.vscode +.idea diff --git a/clients/js/.eslintrc.cjs b/clients/js/.eslintrc.cjs new file mode 100644 index 00000000..63db7b84 --- /dev/null +++ b/clients/js/.eslintrc.cjs @@ -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/.gitignore b/clients/js/.gitignore new file mode 100644 index 00000000..77738287 --- /dev/null +++ b/clients/js/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/clients/js/.prettierrc.cjs b/clients/js/.prettierrc.cjs new file mode 100644 index 00000000..8446d684 --- /dev/null +++ b/clients/js/.prettierrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + endOfLine: 'lf', + semi: true, +}; diff --git a/clients/js/README.md b/clients/js/README.md new file mode 100644 index 00000000..2cd1ff2c --- /dev/null +++ b/clients/js/README.md @@ -0,0 +1,11 @@ +# `@solana/spl-single-pool` + +A TypeScript library for interacting with the SPL Single-Validator Stake Pool program, targeting `@solana/web3.js` 2.0. +**If you are working on the legacy web3.js (if you're not sure, you probably are!), you want `@solana/spl-single-pool-classic`.** + +For information on installation and usage, see [SPL docs](https://spl.solana.com/single-pool). + +For support, please ask questions on the [Solana Stack Exchange](https://solana.stackexchange.com). + +If you've found a bug or you'd like to request a feature, please +[open an issue](https://github.com/solana-labs/solana-program-library/issues/new). diff --git a/clients/js/package.json b/clients/js/package.json new file mode 100644 index 00000000..41161469 --- /dev/null +++ b/clients/js/package.json @@ -0,0 +1,29 @@ +{ + "name": "@solana/spl-single-pool", + "version": "1.0.0", + "main": "dist/cjs/index.js", + "module": "dist/mjs/index.js", + "exports": { + ".": { + "import": "./dist/mjs/index.js", + "require": "./dist/cjs/index.js" + } + }, + "scripts": { + "clean": "rm -fr dist/*", + "build": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./ts-fixup.sh", + "lint": "eslint --max-warnings 0 .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "eslint": "^8.57.0", + "typescript": "^5.7.2" + }, + "dependencies": { + "@solana/addresses": "2.0.0", + "@solana/instructions": "2.0.0", + "@solana/transaction-messages": "2.0.0" + } +} diff --git a/clients/js/src/addresses.ts b/clients/js/src/addresses.ts new file mode 100644 index 00000000..473b7237 --- /dev/null +++ b/clients/js/src/addresses.ts @@ -0,0 +1,117 @@ +import { + address, + getAddressCodec, + getProgramDerivedAddress, + createAddressWithSeed, + Address, +} from '@solana/addresses'; + +import { MPL_METADATA_PROGRAM_ID } from './internal.js'; +import { STAKE_PROGRAM_ID } from './quarantine.js'; + +export const SINGLE_POOL_PROGRAM_ID = address('SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE'); + +export type VoteAccountAddress = Address & { + readonly __voteAccountAddress: unique symbol; +}; + +export type PoolAddress = Address & { + readonly __poolAddress: unique symbol; +}; + +export type PoolStakeAddress = Address & { + readonly __poolStakeAddress: unique symbol; +}; + +export type PoolMintAddress = Address & { + readonly __poolMintAddress: unique symbol; +}; + +export type PoolStakeAuthorityAddress = Address & { + readonly __poolStakeAuthorityAddress: unique symbol; +}; + +export type PoolMintAuthorityAddress = Address & { + readonly __poolMintAuthorityAddress: unique symbol; +}; + +export type PoolMplAuthorityAddress = Address & { + readonly __poolMplAuthorityAddress: unique symbol; +}; + +export async function findPoolAddress( + programId: Address, + voteAccountAddress: VoteAccountAddress, +): Promise { + return (await findPda(programId, voteAccountAddress, 'pool')) as PoolAddress; +} + +export async function findPoolStakeAddress( + programId: Address, + poolAddress: PoolAddress, +): Promise { + return (await findPda(programId, poolAddress, 'stake')) as PoolStakeAddress; +} + +export async function findPoolMintAddress( + programId: Address, + poolAddress: PoolAddress, +): Promise { + return (await findPda(programId, poolAddress, 'mint')) as PoolMintAddress; +} + +export async function findPoolStakeAuthorityAddress( + programId: Address, + poolAddress: PoolAddress, +): Promise { + return (await findPda(programId, poolAddress, 'stake_authority')) as PoolStakeAuthorityAddress; +} + +export async function findPoolMintAuthorityAddress( + programId: Address, + poolAddress: PoolAddress, +): Promise { + return (await findPda(programId, poolAddress, 'mint_authority')) as PoolMintAuthorityAddress; +} + +export async function findPoolMplAuthorityAddress( + programId: Address, + poolAddress: PoolAddress, +): Promise { + return (await findPda(programId, poolAddress, 'mpl_authority')) as PoolMplAuthorityAddress; +} + +async function findPda(programId: Address, baseAddress: Address, prefix: string) { + const { encode } = getAddressCodec(); + const [pda] = await getProgramDerivedAddress({ + programAddress: programId, + seeds: [prefix, encode(baseAddress)], + }); + + return pda; +} + +export async function findDefaultDepositAccountAddress( + poolAddress: PoolAddress, + userWallet: Address, +) { + return createAddressWithSeed({ + baseAddress: userWallet, + seed: defaultDepositAccountSeed(poolAddress), + programAddress: STAKE_PROGRAM_ID, + }); +} + +export function defaultDepositAccountSeed(poolAddress: PoolAddress): string { + return 'svsp' + poolAddress.slice(0, 28); +} + +export async function findMplMetadataAddress(poolMintAddress: PoolMintAddress) { + const { encode } = getAddressCodec(); + const [pda] = await getProgramDerivedAddress({ + programAddress: MPL_METADATA_PROGRAM_ID, + seeds: ['metadata', encode(MPL_METADATA_PROGRAM_ID), encode(poolMintAddress)], + }); + + return pda; +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts new file mode 100644 index 00000000..f739b8ae --- /dev/null +++ b/clients/js/src/index.ts @@ -0,0 +1,19 @@ +import { getAddressCodec } from '@solana/addresses'; + +import { PoolAddress, VoteAccountAddress } from './addresses.js'; + +export * from './addresses.js'; +export * from './instructions.js'; +export * from './transactions.js'; + +export async function getVoteAccountAddressForPool( + rpc: any, // XXX not exported: Rpc, + poolAddress: PoolAddress, + abortSignal?: AbortSignal, +): Promise { + const poolAccount = await rpc.getAccountInfo(poolAddress).send(abortSignal); + if (!(poolAccount && poolAccount.data[0] === 1)) { + throw 'invalid pool address'; + } + return getAddressCodec().decode(poolAccount.data.slice(1)) as VoteAccountAddress; +} diff --git a/clients/js/src/instructions.ts b/clients/js/src/instructions.ts new file mode 100644 index 00000000..5fcaf618 --- /dev/null +++ b/clients/js/src/instructions.ts @@ -0,0 +1,381 @@ +import { getAddressCodec, Address } from '@solana/addresses'; +import { + ReadonlySignerAccount, + ReadonlyAccount, + IInstructionWithAccounts, + IInstructionWithData, + WritableAccount, + WritableSignerAccount, + IInstruction, + AccountRole, +} from '@solana/instructions'; + +import { + PoolMintAuthorityAddress, + PoolMintAddress, + PoolMplAuthorityAddress, + PoolStakeAuthorityAddress, + PoolStakeAddress, + findMplMetadataAddress, + findPoolMplAuthorityAddress, + findPoolAddress, + VoteAccountAddress, + PoolAddress, + findPoolStakeAddress, + findPoolMintAddress, + findPoolMintAuthorityAddress, + findPoolStakeAuthorityAddress, + SINGLE_POOL_PROGRAM_ID, +} from './addresses.js'; +import { MPL_METADATA_PROGRAM_ID } from './internal.js'; +import { + SYSTEM_PROGRAM_ID, + SYSVAR_RENT_ID, + SYSVAR_CLOCK_ID, + STAKE_PROGRAM_ID, + SYSVAR_STAKE_HISTORY_ID, + STAKE_CONFIG_ID, + TOKEN_PROGRAM_ID, + u32, + u64, +} from './quarantine.js'; + +type InitializePoolInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + WritableAccount, + WritableAccount, + WritableAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ] + > & + IInstructionWithData; + +type ReactivatePoolStakeInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + ReadonlyAccount, + WritableAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ] + > & + IInstructionWithData; + +type DepositStakeInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + WritableAccount, + WritableAccount, + ReadonlyAccount, + ReadonlyAccount, + WritableAccount
, // user stake + WritableAccount
, // user token + WritableAccount
, // user lamport + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ] + > & + IInstructionWithData; + +type WithdrawStakeInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + WritableAccount, + WritableAccount, + ReadonlyAccount, + ReadonlyAccount, + WritableAccount
, // user stake + WritableAccount
, // user token + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ] + > & + IInstructionWithData; + +type CreateTokenMetadataInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + WritableSignerAccount
, // mpl payer + WritableAccount
, // mpl account + ReadonlyAccount, + ReadonlyAccount, + ] + > & + IInstructionWithData; + +type UpdateTokenMetadataInstruction = IInstruction & + IInstructionWithAccounts< + [ + ReadonlyAccount, + ReadonlyAccount, + ReadonlyAccount, + ReadonlySignerAccount
, // authorized withdrawer + WritableAccount
, // mpl account + ReadonlyAccount, + ] + > & + IInstructionWithData; + +const enum SinglePoolInstructionType { + InitializePool = 0, + ReactivatePoolStake, + DepositStake, + WithdrawStake, + CreateTokenMetadata, + UpdateTokenMetadata, +} + +export const SinglePoolInstruction = { + initializePool: initializePoolInstruction, + reactivatePoolStake: reactivatePoolStakeInstruction, + depositStake: depositStakeInstruction, + withdrawStake: withdrawStakeInstruction, + createTokenMetadata: createTokenMetadataInstruction, + updateTokenMetadata: updateTokenMetadataInstruction, +}; + +export async function initializePoolInstruction( + voteAccount: VoteAccountAddress, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const pool = await findPoolAddress(programAddress, voteAccount); + const [stake, mint, stakeAuthority, mintAuthority] = await Promise.all([ + findPoolStakeAddress(programAddress, pool), + findPoolMintAddress(programAddress, pool), + findPoolStakeAuthorityAddress(programAddress, pool), + findPoolMintAuthorityAddress(programAddress, pool), + ]); + + const data = new Uint8Array([SinglePoolInstructionType.InitializePool]); + + return { + data, + accounts: [ + { address: voteAccount, role: AccountRole.READONLY }, + { address: pool, role: AccountRole.WRITABLE }, + { address: stake, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.WRITABLE }, + { address: stakeAuthority, role: AccountRole.READONLY }, + { address: mintAuthority, role: AccountRole.READONLY }, + { address: SYSVAR_RENT_ID, role: AccountRole.READONLY }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: SYSVAR_STAKE_HISTORY_ID, role: AccountRole.READONLY }, + { address: STAKE_CONFIG_ID, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ID, role: AccountRole.READONLY }, + { address: STAKE_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} + +export async function reactivatePoolStakeInstruction( + voteAccount: VoteAccountAddress, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const pool = await findPoolAddress(programAddress, voteAccount); + const [stake, stakeAuthority] = await Promise.all([ + findPoolStakeAddress(programAddress, pool), + findPoolStakeAuthorityAddress(programAddress, pool), + ]); + + const data = new Uint8Array([SinglePoolInstructionType.ReactivatePoolStake]); + + return { + data, + accounts: [ + { address: voteAccount, role: AccountRole.READONLY }, + { address: pool, role: AccountRole.READONLY }, + { address: stake, role: AccountRole.WRITABLE }, + { address: stakeAuthority, role: AccountRole.READONLY }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: SYSVAR_STAKE_HISTORY_ID, role: AccountRole.READONLY }, + { address: STAKE_CONFIG_ID, role: AccountRole.READONLY }, + { address: STAKE_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} + +export async function depositStakeInstruction( + pool: PoolAddress, + userStakeAccount: Address, + userTokenAccount: Address, + userLamportAccount: Address, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const [stake, mint, stakeAuthority, mintAuthority] = await Promise.all([ + findPoolStakeAddress(programAddress, pool), + findPoolMintAddress(programAddress, pool), + findPoolStakeAuthorityAddress(programAddress, pool), + findPoolMintAuthorityAddress(programAddress, pool), + ]); + + const data = new Uint8Array([SinglePoolInstructionType.DepositStake]); + + return { + data, + accounts: [ + { address: pool, role: AccountRole.READONLY }, + { address: stake, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.WRITABLE }, + { address: stakeAuthority, role: AccountRole.READONLY }, + { address: mintAuthority, role: AccountRole.READONLY }, + { address: userStakeAccount, role: AccountRole.WRITABLE }, + { address: userTokenAccount, role: AccountRole.WRITABLE }, + { address: userLamportAccount, role: AccountRole.WRITABLE }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: SYSVAR_STAKE_HISTORY_ID, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ID, role: AccountRole.READONLY }, + { address: STAKE_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} + +export async function withdrawStakeInstruction( + pool: PoolAddress, + userStakeAccount: Address, + userStakeAuthority: Address, + userTokenAccount: Address, + tokenAmount: bigint, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const [stake, mint, stakeAuthority, mintAuthority] = await Promise.all([ + findPoolStakeAddress(programAddress, pool), + findPoolMintAddress(programAddress, pool), + findPoolStakeAuthorityAddress(programAddress, pool), + findPoolMintAuthorityAddress(programAddress, pool), + ]); + + const { encode } = getAddressCodec(); + const data = new Uint8Array([ + SinglePoolInstructionType.WithdrawStake, + ...encode(userStakeAuthority), + ...u64(tokenAmount), + ]); + + return { + data, + accounts: [ + { address: pool, role: AccountRole.READONLY }, + { address: stake, role: AccountRole.WRITABLE }, + { address: mint, role: AccountRole.WRITABLE }, + { address: stakeAuthority, role: AccountRole.READONLY }, + { address: mintAuthority, role: AccountRole.READONLY }, + { address: userStakeAccount, role: AccountRole.WRITABLE }, + { address: userTokenAccount, role: AccountRole.WRITABLE }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ID, role: AccountRole.READONLY }, + { address: STAKE_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} + +export async function createTokenMetadataInstruction( + pool: PoolAddress, + payer: Address, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + const mint = await findPoolMintAddress(programAddress, pool); + const [mintAuthority, mplAuthority, mplMetadata] = await Promise.all([ + findPoolMintAuthorityAddress(programAddress, pool), + findPoolMplAuthorityAddress(programAddress, pool), + findMplMetadataAddress(mint), + ]); + + const data = new Uint8Array([SinglePoolInstructionType.CreateTokenMetadata]); + + return { + data, + accounts: [ + { address: pool, role: AccountRole.READONLY }, + { address: mint, role: AccountRole.READONLY }, + { address: mintAuthority, role: AccountRole.READONLY }, + { address: mplAuthority, role: AccountRole.READONLY }, + { address: payer, role: AccountRole.WRITABLE_SIGNER }, + { address: mplMetadata, role: AccountRole.WRITABLE }, + { address: MPL_METADATA_PROGRAM_ID, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} + +export async function updateTokenMetadataInstruction( + voteAccount: VoteAccountAddress, + authorizedWithdrawer: Address, + tokenName: string, + tokenSymbol: string, + tokenUri?: string, +): Promise { + const programAddress = SINGLE_POOL_PROGRAM_ID; + tokenUri = tokenUri || ''; + + if (tokenName.length > 32) { + throw 'maximum token name length is 32 characters'; + } + + if (tokenSymbol.length > 10) { + throw 'maximum token symbol length is 10 characters'; + } + + if (tokenUri.length > 200) { + throw 'maximum token uri length is 200 characters'; + } + + const pool = await findPoolAddress(programAddress, voteAccount); + const [mint, mplAuthority] = await Promise.all([ + findPoolMintAddress(programAddress, pool), + findPoolMplAuthorityAddress(programAddress, pool), + ]); + const mplMetadata = await findMplMetadataAddress(mint); + + const text = new TextEncoder(); + const data = new Uint8Array([ + SinglePoolInstructionType.UpdateTokenMetadata, + ...u32(tokenName.length), + ...text.encode(tokenName), + ...u32(tokenSymbol.length), + ...text.encode(tokenSymbol), + ...u32(tokenUri.length), + ...text.encode(tokenUri), + ]); + + return { + data, + accounts: [ + { address: voteAccount, role: AccountRole.READONLY }, + { address: pool, role: AccountRole.READONLY }, + { address: mplAuthority, role: AccountRole.READONLY }, + { address: authorizedWithdrawer, role: AccountRole.READONLY_SIGNER }, + { address: mplMetadata, role: AccountRole.WRITABLE }, + { address: MPL_METADATA_PROGRAM_ID, role: AccountRole.READONLY }, + ], + programAddress, + }; +} diff --git a/clients/js/src/internal.ts b/clients/js/src/internal.ts new file mode 100644 index 00000000..6ade8ed2 --- /dev/null +++ b/clients/js/src/internal.ts @@ -0,0 +1,3 @@ +import { address } from '@solana/addresses'; + +export const MPL_METADATA_PROGRAM_ID = address('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s'); diff --git a/clients/js/src/quarantine.ts b/clients/js/src/quarantine.ts new file mode 100644 index 00000000..b3f93a74 --- /dev/null +++ b/clients/js/src/quarantine.ts @@ -0,0 +1,236 @@ +import { address, getAddressCodec, getProgramDerivedAddress, Address } from '@solana/addresses'; +import { AccountRole } from '@solana/instructions'; + +// HERE BE DRAGONS +// this is all the stuff that shouldn't be in our library once we can import from elsewhere + +export const SYSTEM_PROGRAM_ID = address('11111111111111111111111111111111'); +export const STAKE_PROGRAM_ID = address('Stake11111111111111111111111111111111111111'); +export const SYSVAR_RENT_ID = address('SysvarRent111111111111111111111111111111111'); +export const SYSVAR_CLOCK_ID = address('SysvarC1ock11111111111111111111111111111111'); +export const SYSVAR_STAKE_HISTORY_ID = address('SysvarStakeHistory1111111111111111111111111'); +export const STAKE_CONFIG_ID = address('StakeConfig11111111111111111111111111111111'); +export const STAKE_ACCOUNT_SIZE = 200n; + +export const TOKEN_PROGRAM_ID = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); +export const ATOKEN_PROGRAM_ID = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); +export const MINT_SIZE = 82n; + +export function u32(n: number): Uint8Array { + const bns = Uint32Array.from([n]); + return new Uint8Array(bns.buffer); +} + +export function u64(n: bigint): Uint8Array { + const bns = BigUint64Array.from([n]); + return new Uint8Array(bns.buffer); +} + +export class SystemInstruction { + static createAccount(params: { + from: Address; + newAccount: Address; + lamports: bigint; + space: bigint; + programAddress: Address; + }) { + const { encode } = getAddressCodec(); + const data = new Uint8Array([ + ...u32(0), + ...u64(params.lamports), + ...u64(params.space), + ...encode(params.programAddress), + ]); + + const accounts = [ + { address: params.from, role: AccountRole.WRITABLE_SIGNER }, + { address: params.newAccount, role: AccountRole.WRITABLE_SIGNER }, + ]; + + return { + data, + accounts, + programAddress: SYSTEM_PROGRAM_ID, + }; + } + + static transfer(params: { from: Address; to: Address; lamports: bigint }) { + const data = new Uint8Array([...u32(2), ...u64(params.lamports)]); + + const accounts = [ + { address: params.from, role: AccountRole.WRITABLE_SIGNER }, + { address: params.to, role: AccountRole.WRITABLE }, + ]; + + return { + data, + accounts, + programAddress: SYSTEM_PROGRAM_ID, + }; + } + + static createAccountWithSeed(params: { + from: Address; + newAccount: Address; + base: Address; + seed: string; + lamports: bigint; + space: bigint; + programAddress: Address; + }) { + const { encode } = getAddressCodec(); + const data = new Uint8Array([ + ...u32(3), + ...encode(params.base), + ...u64(BigInt(params.seed.length)), + ...new TextEncoder().encode(params.seed), + ...u64(params.lamports), + ...u64(params.space), + ...encode(params.programAddress), + ]); + + const accounts = [ + { address: params.from, role: AccountRole.WRITABLE_SIGNER }, + { address: params.newAccount, role: AccountRole.WRITABLE }, + ]; + if (params.base != params.from) { + accounts.push({ address: params.base, role: AccountRole.READONLY_SIGNER }); + } + + return { + data, + accounts, + programAddress: SYSTEM_PROGRAM_ID, + }; + } +} + +export class TokenInstruction { + static approve(params: { account: Address; delegate: Address; owner: Address; amount: bigint }) { + const data = new Uint8Array([...u32(4), ...u64(params.amount)]); + + const accounts = [ + { address: params.account, role: AccountRole.WRITABLE }, + { address: params.delegate, role: AccountRole.READONLY }, + { address: params.owner, role: AccountRole.READONLY_SIGNER }, + ]; + + return { + data, + accounts, + programAddress: TOKEN_PROGRAM_ID, + }; + } + + static createAssociatedTokenAccount(params: { + payer: Address; + associatedAccount: Address; + owner: Address; + mint: Address; + }) { + const data = new Uint8Array([0]); + + const accounts = [ + { address: params.payer, role: AccountRole.WRITABLE_SIGNER }, + { address: params.associatedAccount, role: AccountRole.WRITABLE }, + { address: params.owner, role: AccountRole.READONLY }, + { address: params.mint, role: AccountRole.READONLY }, + { address: SYSTEM_PROGRAM_ID, role: AccountRole.READONLY }, + { address: TOKEN_PROGRAM_ID, role: AccountRole.READONLY }, + ]; + + return { + data, + accounts, + programAddress: ATOKEN_PROGRAM_ID, + }; + } +} + +export enum StakeAuthorizationType { + Staker, + Withdrawer, +} + +export class StakeInstruction { + // idc about doing it right unless this goes in a lib + static initialize(params: { stakeAccount: Address; staker: Address; withdrawer: Address }) { + const { encode } = getAddressCodec(); + const data = new Uint8Array([ + ...u32(0), + ...encode(params.staker), + ...encode(params.withdrawer), + ...Array(48).fill(0), + ]); + + const accounts = [ + { address: params.stakeAccount, role: AccountRole.WRITABLE }, + { address: SYSVAR_RENT_ID, role: AccountRole.READONLY }, + ]; + + return { + data, + accounts, + programAddress: STAKE_PROGRAM_ID, + }; + } + + static authorize(params: { + stakeAccount: Address; + authorized: Address; + newAuthorized: Address; + authorizationType: StakeAuthorizationType; + custodian?: Address; + }) { + const { encode } = getAddressCodec(); + const data = new Uint8Array([ + ...u32(1), + ...encode(params.newAuthorized), + ...u32(params.authorizationType), + ]); + + const accounts = [ + { address: params.stakeAccount, role: AccountRole.WRITABLE }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: params.authorized, role: AccountRole.READONLY_SIGNER }, + ]; + if (params.custodian) { + accounts.push({ address: params.custodian, role: AccountRole.READONLY }); + } + + return { + data, + accounts, + programAddress: STAKE_PROGRAM_ID, + }; + } + + static delegate(params: { stakeAccount: Address; authorized: Address; voteAccount: Address }) { + const data = new Uint8Array(u32(2)); + + const accounts = [ + { address: params.stakeAccount, role: AccountRole.WRITABLE }, + { address: params.voteAccount, role: AccountRole.READONLY }, + { address: SYSVAR_CLOCK_ID, role: AccountRole.READONLY }, + { address: SYSVAR_STAKE_HISTORY_ID, role: AccountRole.READONLY }, + { address: STAKE_CONFIG_ID, role: AccountRole.READONLY }, + { address: params.authorized, role: AccountRole.READONLY_SIGNER }, + ]; + + return { + data, + accounts, + programAddress: STAKE_PROGRAM_ID, + }; + } +} + +export async function getAssociatedTokenAddress(mint: Address, owner: Address) { + const { encode } = getAddressCodec(); + const [pda] = await getProgramDerivedAddress({ + programAddress: ATOKEN_PROGRAM_ID, + seeds: [encode(owner), encode(TOKEN_PROGRAM_ID), encode(mint)], + }); + + return pda; +} diff --git a/clients/js/src/transactions.ts b/clients/js/src/transactions.ts new file mode 100644 index 00000000..7ad7b257 --- /dev/null +++ b/clients/js/src/transactions.ts @@ -0,0 +1,347 @@ +import { Address } from '@solana/addresses'; +import { + appendTransactionMessageInstruction, + createTransactionMessage, + TransactionVersion, + TransactionMessage, +} from '@solana/transaction-messages'; + +import { + findPoolAddress, + VoteAccountAddress, + PoolAddress, + findPoolStakeAddress, + findPoolMintAddress, + defaultDepositAccountSeed, + findDefaultDepositAccountAddress, + findPoolMintAuthorityAddress, + findPoolStakeAuthorityAddress, + SINGLE_POOL_PROGRAM_ID, +} from './addresses.js'; +import { + initializePoolInstruction, + reactivatePoolStakeInstruction, + depositStakeInstruction, + withdrawStakeInstruction, + createTokenMetadataInstruction, + updateTokenMetadataInstruction, +} from './instructions.js'; +import { + STAKE_PROGRAM_ID, + STAKE_ACCOUNT_SIZE, + MINT_SIZE, + StakeInstruction, + SystemInstruction, + TokenInstruction, + StakeAuthorizationType, + getAssociatedTokenAddress, +} from './quarantine.js'; + +interface DepositParams { + rpc: any; // XXX Rpc + pool: PoolAddress; + userWallet: Address; + userStakeAccount?: Address; + depositFromDefaultAccount?: boolean; + userTokenAccount?: Address; + userLamportAccount?: Address; + userWithdrawAuthority?: Address; +} + +interface WithdrawParams { + rpc: any; // XXX Rpc + pool: PoolAddress; + userWallet: Address; + userStakeAccount: Address; + tokenAmount: bigint; + createStakeAccount?: boolean; + userStakeAuthority?: Address; + userTokenAccount?: Address; + userTokenAuthority?: Address; +} + +export const SINGLE_POOL_ACCOUNT_SIZE = 33n; + +export const SinglePoolProgram = { + programAddress: SINGLE_POOL_PROGRAM_ID, + space: SINGLE_POOL_ACCOUNT_SIZE, + initialize: initializeTransaction, + reactivatePoolStake: reactivatePoolStakeTransaction, + deposit: depositTransaction, + withdraw: withdrawTransaction, + createTokenMetadata: createTokenMetadataTransaction, + updateTokenMetadata: updateTokenMetadataTransaction, + createAndDelegateUserStake: createAndDelegateUserStakeTransaction, +}; + +export async function initializeTransaction( + rpc: any, // XXX not exported: Rpc, + voteAccount: VoteAccountAddress, + payer: Address, + skipMetadata = false, +): Promise { + let transaction = createTransactionMessage({ version: 0 }); + + const pool = await findPoolAddress(SINGLE_POOL_PROGRAM_ID, voteAccount); + const [stake, mint, poolRent, stakeRent, mintRent, minimumDelegationObj] = await Promise.all([ + findPoolStakeAddress(SINGLE_POOL_PROGRAM_ID, pool), + findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, pool), + rpc.getMinimumBalanceForRentExemption(SINGLE_POOL_ACCOUNT_SIZE).send(), + rpc.getMinimumBalanceForRentExemption(STAKE_ACCOUNT_SIZE).send(), + rpc.getMinimumBalanceForRentExemption(MINT_SIZE).send(), + rpc.getStakeMinimumDelegation().send(), + ]); + const minimumDelegation = minimumDelegationObj.value; + + transaction = appendTransactionMessageInstruction( + SystemInstruction.transfer({ + from: payer, + to: pool, + lamports: poolRent, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + SystemInstruction.transfer({ + from: payer, + to: stake, + lamports: stakeRent + minimumDelegation, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + SystemInstruction.transfer({ + from: payer, + to: mint, + lamports: mintRent, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + await initializePoolInstruction(voteAccount), + transaction, + ); + + if (!skipMetadata) { + transaction = appendTransactionMessageInstruction( + await createTokenMetadataInstruction(pool, payer), + transaction, + ); + } + + return transaction; +} + +export async function reactivatePoolStakeTransaction( + voteAccount: VoteAccountAddress, +): Promise { + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + transaction = appendTransactionMessageInstruction( + await reactivatePoolStakeInstruction(voteAccount), + transaction, + ); + + return transaction; +} + +export async function depositTransaction(params: DepositParams) { + const { rpc, pool, userWallet } = params; + + // note this is just xnor + if (!params.userStakeAccount == !params.depositFromDefaultAccount) { + throw 'must either provide userStakeAccount or true depositFromDefaultAccount'; + } + + const userStakeAccount = ( + params.depositFromDefaultAccount + ? await findDefaultDepositAccountAddress(pool, userWallet) + : params.userStakeAccount + ) as Address; + + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + + const [mint, poolStakeAuthority] = await Promise.all([ + findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, pool), + findPoolStakeAuthorityAddress(SINGLE_POOL_PROGRAM_ID, pool), + ]); + + const userAssociatedTokenAccount = await getAssociatedTokenAddress(mint, userWallet); + const userTokenAccount = params.userTokenAccount || userAssociatedTokenAccount; + const userLamportAccount = params.userLamportAccount || userWallet; + const userWithdrawAuthority = params.userWithdrawAuthority || userWallet; + + if ( + userTokenAccount == userAssociatedTokenAccount && + (await rpc.getAccountInfo(userAssociatedTokenAccount).send()) == null + ) { + transaction = appendTransactionMessageInstruction( + TokenInstruction.createAssociatedTokenAccount({ + payer: userWallet, + associatedAccount: userAssociatedTokenAccount, + owner: userWallet, + mint, + }), + transaction, + ); + } + + transaction = appendTransactionMessageInstruction( + StakeInstruction.authorize({ + stakeAccount: userStakeAccount, + authorized: userWithdrawAuthority, + newAuthorized: poolStakeAuthority, + authorizationType: StakeAuthorizationType.Staker, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + StakeInstruction.authorize({ + stakeAccount: userStakeAccount, + authorized: userWithdrawAuthority, + newAuthorized: poolStakeAuthority, + authorizationType: StakeAuthorizationType.Withdrawer, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + await depositStakeInstruction(pool, userStakeAccount, userTokenAccount, userLamportAccount), + transaction, + ); + + return transaction; +} + +export async function withdrawTransaction(params: WithdrawParams) { + const { rpc, pool, userWallet, userStakeAccount, tokenAmount, createStakeAccount } = params; + + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + + const poolMintAuthority = await findPoolMintAuthorityAddress(SINGLE_POOL_PROGRAM_ID, pool); + + const userStakeAuthority = params.userStakeAuthority || userWallet; + const userTokenAccount = + params.userTokenAccount || + (await getAssociatedTokenAddress( + await findPoolMintAddress(SINGLE_POOL_PROGRAM_ID, pool), + userWallet, + )); + const userTokenAuthority = params.userTokenAuthority || userWallet; + + if (createStakeAccount) { + transaction = appendTransactionMessageInstruction( + SystemInstruction.createAccount({ + from: userWallet, + lamports: await rpc.getMinimumBalanceForRentExemption(STAKE_ACCOUNT_SIZE).send(), + newAccount: userStakeAccount, + programAddress: STAKE_PROGRAM_ID, + space: STAKE_ACCOUNT_SIZE, + }), + transaction, + ); + } + + transaction = appendTransactionMessageInstruction( + TokenInstruction.approve({ + account: userTokenAccount, + delegate: poolMintAuthority, + owner: userTokenAuthority, + amount: tokenAmount, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + await withdrawStakeInstruction( + pool, + userStakeAccount, + userStakeAuthority, + userTokenAccount, + tokenAmount, + ), + transaction, + ); + + return transaction; +} + +export async function createTokenMetadataTransaction( + pool: PoolAddress, + payer: Address, +): Promise { + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + transaction = appendTransactionMessageInstruction( + await createTokenMetadataInstruction(pool, payer), + transaction, + ); + + return transaction; +} + +export async function updateTokenMetadataTransaction( + voteAccount: VoteAccountAddress, + authorizedWithdrawer: Address, + name: string, + symbol: string, + uri?: string, +): Promise { + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + transaction = appendTransactionMessageInstruction( + await updateTokenMetadataInstruction(voteAccount, authorizedWithdrawer, name, symbol, uri), + transaction, + ); + + return transaction; +} + +export async function createAndDelegateUserStakeTransaction( + rpc: any, // XXX not exported: Rpc, + voteAccount: VoteAccountAddress, + userWallet: Address, + stakeAmount: bigint, +): Promise { + let transaction = { instructions: [] as any, version: 'legacy' as TransactionVersion }; + + const pool = await findPoolAddress(SINGLE_POOL_PROGRAM_ID, voteAccount); + const [stakeAccount, stakeRent] = await Promise.all([ + findDefaultDepositAccountAddress(pool, userWallet), + await rpc.getMinimumBalanceForRentExemption(STAKE_ACCOUNT_SIZE).send(), + ]); + + transaction = appendTransactionMessageInstruction( + SystemInstruction.createAccountWithSeed({ + base: userWallet, + from: userWallet, + lamports: stakeAmount + stakeRent, + newAccount: stakeAccount, + programAddress: STAKE_PROGRAM_ID, + seed: defaultDepositAccountSeed(pool), + space: STAKE_ACCOUNT_SIZE, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + StakeInstruction.initialize({ + stakeAccount, + staker: userWallet, + withdrawer: userWallet, + }), + transaction, + ); + + transaction = appendTransactionMessageInstruction( + StakeInstruction.delegate({ + stakeAccount, + authorized: userWallet, + voteAccount, + }), + transaction, + ); + + return transaction; +} diff --git a/clients/js/ts-fixup.sh b/clients/js/ts-fixup.sh new file mode 120000 index 00000000..2b79c262 --- /dev/null +++ b/clients/js/ts-fixup.sh @@ -0,0 +1 @@ +../../ts-fixup.sh \ No newline at end of file diff --git a/clients/js/tsconfig-base.json b/clients/js/tsconfig-base.json new file mode 120000 index 00000000..8cdeff68 --- /dev/null +++ b/clients/js/tsconfig-base.json @@ -0,0 +1 @@ +../../tsconfig-base.json \ No newline at end of file diff --git a/clients/js/tsconfig-cjs.json b/clients/js/tsconfig-cjs.json new file mode 120000 index 00000000..eb5b6778 --- /dev/null +++ b/clients/js/tsconfig-cjs.json @@ -0,0 +1 @@ +../../tsconfig-cjs.json \ No newline at end of file diff --git a/clients/js/tsconfig.json b/clients/js/tsconfig.json new file mode 120000 index 00000000..fd0e4743 --- /dev/null +++ b/clients/js/tsconfig.json @@ -0,0 +1 @@ +../../tsconfig.json \ No newline at end of file diff --git a/program/Cargo.toml b/program/Cargo.toml new file mode 100644 index 00000000..f7c1dd9b --- /dev/null +++ b/program/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "spl-single-pool" +version = "1.0.1" +description = "Solana Program Library Single-Validator 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" +num-derive = "0.4" +num-traits = "0.2" +num_enum = "0.7.3" +solana-program = "2.1.0" +solana-security-txt = "1.1.1" +spl-token = { version = "7.0", path = "../../token/program", features = [ + "no-entrypoint", +] } +thiserror = "2.0" + +[dev-dependencies] +solana-program-test = "2.1.0" +solana-sdk = "2.1.0" +solana-vote-program = "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" } +test-case = "3.3" +bincode = "1.3.1" +rand = "0.8.5" +approx = "0.5.1" + +[lib] +crate-type = ["cdylib", "lib"] + +[lints] +workspace = true diff --git a/program/program-id.md b/program/program-id.md new file mode 100644 index 00000000..e873b000 --- /dev/null +++ b/program/program-id.md @@ -0,0 +1 @@ +SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs new file mode 100644 index 00000000..07d7ae60 --- /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::SinglePoolError, 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 Single-Validator Stake Pool", + project_url: "https://spl.solana.com/single-pool", + contacts: "link:https://github.com/solana-labs/solana-program-library/security/advisories/new,mailto:security@solana.com,discord:https://discord.gg/solana", + 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/single-pool/program", + source_revision: "ef44df985e76a697ee9a8aabb3a223610e4cf1dc", + source_release: "single-pool-v1.0.0", + auditors: "https://github.com/solana-labs/security-audits#single-stake-pool" +} diff --git a/program/src/error.rs b/program/src/error.rs new file mode 100644 index 00000000..08776849 --- /dev/null +++ b/program/src/error.rs @@ -0,0 +1,157 @@ +//! Error types + +use { + solana_program::{ + decode_error::DecodeError, + msg, + program_error::{PrintProgramError, ProgramError}, + }, + thiserror::Error, +}; + +/// Errors that may be returned by the SinglePool program. +#[derive(Clone, Debug, Eq, Error, num_derive::FromPrimitive, PartialEq)] +pub enum SinglePoolError { + // 0. + /// Provided pool account has the wrong address for its vote account, is + /// uninitialized, or otherwise invalid. + #[error("InvalidPoolAccount")] + InvalidPoolAccount, + /// Provided pool stake account does not match address derived from the pool + /// account. + #[error("InvalidPoolStakeAccount")] + InvalidPoolStakeAccount, + /// Provided pool mint does not match address derived from the pool account. + #[error("InvalidPoolMint")] + InvalidPoolMint, + /// Provided pool stake authority does not match address derived from the + /// pool account. + #[error("InvalidPoolStakeAuthority")] + InvalidPoolStakeAuthority, + /// Provided pool mint authority does not match address derived from the + /// pool account. + #[error("InvalidPoolMintAuthority")] + InvalidPoolMintAuthority, + + // 5. + /// Provided pool MPL authority does not match address derived from the pool + /// account. + #[error("InvalidPoolMplAuthority")] + InvalidPoolMplAuthority, + /// Provided metadata account does not match metadata account derived for + /// pool mint. + #[error("InvalidMetadataAccount")] + InvalidMetadataAccount, + /// Authorized withdrawer provided for metadata update does not match the + /// vote account. + #[error("InvalidMetadataSigner")] + InvalidMetadataSigner, + /// Not enough lamports provided for deposit to result in one pool token. + #[error("DepositTooSmall")] + DepositTooSmall, + /// Not enough pool tokens provided to withdraw stake worth one lamport. + #[error("WithdrawalTooSmall")] + WithdrawalTooSmall, + + // 10 + /// Not enough stake to cover the provided quantity of pool tokens. + /// (Generally this should not happen absent user error, but may if the + /// minimum delegation increases.) + #[error("WithdrawalTooLarge")] + WithdrawalTooLarge, + /// Required signature is missing. + #[error("SignatureMissing")] + SignatureMissing, + /// Stake account is not in the state expected by the program. + #[error("WrongStakeStake")] + WrongStakeStake, + /// Unsigned subtraction crossed the zero. + #[error("ArithmeticOverflow")] + ArithmeticOverflow, + /// A calculation failed unexpectedly. + /// (This error should never be surfaced; it stands in for failure + /// conditions that should never be reached.) + #[error("UnexpectedMathError")] + UnexpectedMathError, + + // 15 + /// The V0_23_5 vote account type is unsupported and should be upgraded via + /// `convert_to_current()`. + #[error("LegacyVoteAccount")] + LegacyVoteAccount, + /// Failed to parse vote account. + #[error("UnparseableVoteAccount")] + UnparseableVoteAccount, + /// Incorrect number of lamports provided for rent-exemption when + /// initializing. + #[error("WrongRentAmount")] + WrongRentAmount, + /// Attempted to deposit from or withdraw to pool stake account. + #[error("InvalidPoolStakeAccountUsage")] + InvalidPoolStakeAccountUsage, + /// Attempted to initialize a pool that is already initialized. + #[error("PoolAlreadyInitialized")] + PoolAlreadyInitialized, +} +impl From for ProgramError { + fn from(e: SinglePoolError) -> Self { + ProgramError::Custom(e as u32) + } +} +impl DecodeError for SinglePoolError { + fn type_of() -> &'static str { + "Single-Validator Stake Pool Error" + } +} +impl PrintProgramError for SinglePoolError { + fn print(&self) + where + E: 'static + + std::error::Error + + DecodeError + + PrintProgramError + + num_traits::FromPrimitive, + { + match self { + SinglePoolError::InvalidPoolAccount => + msg!("Error: Provided pool account has the wrong address for its vote account, is uninitialized, \ + or is otherwise invalid."), + SinglePoolError::InvalidPoolStakeAccount => + msg!("Error: Provided pool stake account does not match address derived from the pool account."), + SinglePoolError::InvalidPoolMint => + msg!("Error: Provided pool mint does not match address derived from the pool account."), + SinglePoolError::InvalidPoolStakeAuthority => + msg!("Error: Provided pool stake authority does not match address derived from the pool account."), + SinglePoolError::InvalidPoolMintAuthority => + msg!("Error: Provided pool mint authority does not match address derived from the pool account."), + SinglePoolError::InvalidPoolMplAuthority => + msg!("Error: Provided pool MPL authority does not match address derived from the pool account."), + SinglePoolError::InvalidMetadataAccount => + msg!("Error: Provided metadata account does not match metadata account derived for pool mint."), + SinglePoolError::InvalidMetadataSigner => + msg!("Error: Authorized withdrawer provided for metadata update does not match the vote account."), + SinglePoolError::DepositTooSmall => + msg!("Error: Not enough lamports provided for deposit to result in one pool token."), + SinglePoolError::WithdrawalTooSmall => + msg!("Error: Not enough pool tokens provided to withdraw stake worth one lamport."), + SinglePoolError::WithdrawalTooLarge => + msg!("Error: Not enough stake to cover the provided quantity of pool tokens. \ + (Generally this should not happen absent user error, but may if the minimum delegation increases.)"), + SinglePoolError::SignatureMissing => msg!("Error: Required signature is missing."), + SinglePoolError::WrongStakeStake => msg!("Error: Stake account is not in the state expected by the program."), + SinglePoolError::ArithmeticOverflow => msg!("Error: Unsigned subtraction crossed the zero."), + SinglePoolError::UnexpectedMathError => + msg!("Error: A calculation failed unexpectedly. \ + (This error should never be surfaced; it stands in for failure conditions that should never be reached.)"), + SinglePoolError::UnparseableVoteAccount => msg!("Error: Failed to parse vote account."), + SinglePoolError::LegacyVoteAccount => + msg!("Error: The V0_23_5 vote account type is unsupported and should be upgraded via `convert_to_current()`."), + SinglePoolError::WrongRentAmount => + msg!("Error: Incorrect number of lamports provided for rent-exemption when initializing."), + SinglePoolError::InvalidPoolStakeAccountUsage => + msg!("Error: Attempted to deposit from or withdraw to pool stake account."), + SinglePoolError::PoolAlreadyInitialized => + msg!("Error: Attempted to initialize a pool that is already initialized."), + } + } +} diff --git a/program/src/inline_mpl_token_metadata.rs b/program/src/inline_mpl_token_metadata.rs new file mode 120000 index 00000000..144225f4 --- /dev/null +++ b/program/src/inline_mpl_token_metadata.rs @@ -0,0 +1 @@ +../../../stake-pool/program/src/inline_mpl_token_metadata.rs \ No newline at end of file diff --git a/program/src/instruction.rs b/program/src/instruction.rs new file mode 100644 index 00000000..fda54691 --- /dev/null +++ b/program/src/instruction.rs @@ -0,0 +1,473 @@ +//! Instruction types + +#![allow(clippy::too_many_arguments)] + +use { + crate::{ + find_default_deposit_account_address_and_seed, find_pool_address, find_pool_mint_address, + find_pool_mint_authority_address, find_pool_mpl_authority_address, find_pool_stake_address, + find_pool_stake_authority_address, + inline_mpl_token_metadata::{self, pda::find_metadata_account}, + state::SinglePool, + }, + borsh::{BorshDeserialize, BorshSerialize}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + program_pack::Pack, + pubkey::Pubkey, + rent::Rent, + stake, system_instruction, system_program, sysvar, + }, +}; + +/// Instructions supported by the SinglePool program. +#[repr(C)] +#[derive(Clone, Debug, PartialEq, BorshSerialize, BorshDeserialize)] +pub enum SinglePoolInstruction { + /// Initialize the mint and stake account for a new single-validator + /// stake pool. The pool stake account must contain the rent-exempt + /// minimum plus the minimum delegation. No tokens will be minted: to + /// deposit more, use `Deposit` after `InitializeStake`. + /// + /// 0. `[]` Validator vote account + /// 1. `[w]` Pool account + /// 2. `[w]` Pool stake account + /// 3. `[w]` Pool token mint + /// 4. `[]` Pool stake authority + /// 5. `[]` Pool mint authority + /// 6. `[]` Rent sysvar + /// 7. `[]` Clock sysvar + /// 8. `[]` Stake history sysvar + /// 9. `[]` Stake config sysvar + /// 10. `[]` System program + /// 11. `[]` Token program + /// 12. `[]` Stake program + InitializePool, + + /// Restake the pool stake account if it was deactivated. This can + /// happen through the stake program's `DeactivateDelinquent` + /// instruction, or during a cluster restart. + /// + /// 0. `[]` Validator vote account + /// 1. `[]` Pool account + /// 2. `[w]` Pool stake account + /// 3. `[]` Pool stake authority + /// 4. `[]` Clock sysvar + /// 5. `[]` Stake history sysvar + /// 6. `[]` Stake config sysvar + /// 7. `[]` Stake program + ReactivatePoolStake, + + /// Deposit stake into the pool. The output is a "pool" token + /// representing fractional ownership of the pool stake. Inputs are + /// converted to the current ratio. + /// + /// 0. `[]` Pool account + /// 1. `[w]` Pool stake account + /// 2. `[w]` Pool token mint + /// 3. `[]` Pool stake authority + /// 4. `[]` Pool mint authority + /// 5. `[w]` User stake account to join to the pool + /// 6. `[w]` User account to receive pool tokens + /// 7. `[w]` User account to receive lamports + /// 8. `[]` Clock sysvar + /// 9. `[]` Stake history sysvar + /// 10. `[]` Token program + /// 11. `[]` Stake program + DepositStake, + + /// Redeem tokens issued by this pool for stake at the current ratio. + /// + /// 0. `[]` Pool account + /// 1. `[w]` Pool stake account + /// 2. `[w]` Pool token mint + /// 3. `[]` Pool stake authority + /// 4. `[]` Pool mint authority + /// 5. `[w]` User stake account to receive stake at + /// 6. `[w]` User account to take pool tokens from + /// 7. `[]` Clock sysvar + /// 8. `[]` Token program + /// 9. `[]` Stake program + WithdrawStake { + /// User authority for the new stake account + user_stake_authority: Pubkey, + /// Amount of tokens to redeem for stake + token_amount: u64, + }, + + /// Create token metadata for the stake-pool token in the metaplex-token + /// program. Step three of the permissionless three-stage initialization + /// flow. + /// Note this instruction is not necessary for the pool to operate, to + /// ensure we cannot be broken by upstream. + /// + /// 0. `[]` Pool account + /// 1. `[]` Pool token mint + /// 2. `[]` Pool mint authority + /// 3. `[]` Pool MPL authority + /// 4. `[s, w]` Payer for creation of token metadata account + /// 5. `[w]` Token metadata account + /// 6. `[]` Metadata program id + /// 7. `[]` System program id + CreateTokenMetadata, + + /// Update token metadata for the stake-pool token in the metaplex-token + /// program. + /// + /// 0. `[]` Validator vote account + /// 1. `[]` Pool account + /// 2. `[]` Pool MPL authority + /// 3. `[s]` Vote account authorized withdrawer + /// 4. `[w]` Token metadata account + /// 5. `[]` 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, + }, +} + +/// Creates all necessary instructions to initialize the stake pool. +pub fn initialize( + program_id: &Pubkey, + vote_account_address: &Pubkey, + payer: &Pubkey, + rent: &Rent, + minimum_delegation: u64, +) -> Vec { + let pool_address = find_pool_address(program_id, vote_account_address); + let pool_rent = rent.minimum_balance(std::mem::size_of::()); + + let stake_address = find_pool_stake_address(program_id, &pool_address); + let stake_space = std::mem::size_of::(); + let stake_rent_plus_minimum = rent + .minimum_balance(stake_space) + .saturating_add(minimum_delegation); + + let mint_address = find_pool_mint_address(program_id, &pool_address); + let mint_rent = rent.minimum_balance(spl_token::state::Mint::LEN); + + vec![ + system_instruction::transfer(payer, &pool_address, pool_rent), + system_instruction::transfer(payer, &stake_address, stake_rent_plus_minimum), + system_instruction::transfer(payer, &mint_address, mint_rent), + initialize_pool(program_id, vote_account_address), + create_token_metadata(program_id, &pool_address, payer), + ] +} + +/// Creates an `InitializePool` instruction. +pub fn initialize_pool(program_id: &Pubkey, vote_account_address: &Pubkey) -> Instruction { + let pool_address = find_pool_address(program_id, vote_account_address); + let mint_address = find_pool_mint_address(program_id, &pool_address); + + let data = borsh::to_vec(&SinglePoolInstruction::InitializePool).unwrap(); + let accounts = vec![ + AccountMeta::new_readonly(*vote_account_address, false), + AccountMeta::new(pool_address, false), + AccountMeta::new(find_pool_stake_address(program_id, &pool_address), false), + AccountMeta::new(mint_address, false), + AccountMeta::new_readonly( + find_pool_stake_authority_address(program_id, &pool_address), + false, + ), + AccountMeta::new_readonly( + find_pool_mint_authority_address(program_id, &pool_address), + 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(spl_token::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates a `ReactivatePoolStake` instruction. +pub fn reactivate_pool_stake(program_id: &Pubkey, vote_account_address: &Pubkey) -> Instruction { + let pool_address = find_pool_address(program_id, vote_account_address); + + let data = borsh::to_vec(&SinglePoolInstruction::ReactivatePoolStake).unwrap(); + let accounts = vec![ + AccountMeta::new_readonly(*vote_account_address, false), + AccountMeta::new_readonly(pool_address, false), + AccountMeta::new(find_pool_stake_address(program_id, &pool_address), false), + AccountMeta::new_readonly( + find_pool_stake_authority_address(program_id, &pool_address), + 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(stake::program::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates all necessary instructions to deposit stake. +pub fn deposit( + program_id: &Pubkey, + pool_address: &Pubkey, + user_stake_account: &Pubkey, + user_token_account: &Pubkey, + user_lamport_account: &Pubkey, + user_withdraw_authority: &Pubkey, +) -> Vec { + let pool_stake_authority = find_pool_stake_authority_address(program_id, pool_address); + + vec![ + stake::instruction::authorize( + user_stake_account, + user_withdraw_authority, + &pool_stake_authority, + stake::state::StakeAuthorize::Staker, + None, + ), + stake::instruction::authorize( + user_stake_account, + user_withdraw_authority, + &pool_stake_authority, + stake::state::StakeAuthorize::Withdrawer, + None, + ), + deposit_stake( + program_id, + pool_address, + user_stake_account, + user_token_account, + user_lamport_account, + ), + ] +} + +/// Creates a `DepositStake` instruction. +pub fn deposit_stake( + program_id: &Pubkey, + pool_address: &Pubkey, + user_stake_account: &Pubkey, + user_token_account: &Pubkey, + user_lamport_account: &Pubkey, +) -> Instruction { + let data = borsh::to_vec(&SinglePoolInstruction::DepositStake).unwrap(); + + let accounts = vec![ + AccountMeta::new_readonly(*pool_address, false), + AccountMeta::new(find_pool_stake_address(program_id, pool_address), false), + AccountMeta::new(find_pool_mint_address(program_id, pool_address), false), + AccountMeta::new_readonly( + find_pool_stake_authority_address(program_id, pool_address), + false, + ), + AccountMeta::new_readonly( + find_pool_mint_authority_address(program_id, pool_address), + false, + ), + AccountMeta::new(*user_stake_account, false), + AccountMeta::new(*user_token_account, false), + AccountMeta::new(*user_lamport_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(sysvar::stake_history::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates all necessary instructions to withdraw stake into a given stake +/// account. If a new stake account is required, the user should first include +/// `system_instruction::create_account` with account size +/// `std::mem::size_of::()` and owner +/// `stake::program::id()`. +pub fn withdraw( + program_id: &Pubkey, + pool_address: &Pubkey, + user_stake_account: &Pubkey, + user_stake_authority: &Pubkey, + user_token_account: &Pubkey, + user_token_authority: &Pubkey, + token_amount: u64, +) -> Vec { + vec![ + spl_token::instruction::approve( + &spl_token::id(), + user_token_account, + &find_pool_mint_authority_address(program_id, pool_address), + user_token_authority, + &[], + token_amount, + ) + .unwrap(), + withdraw_stake( + program_id, + pool_address, + user_stake_account, + user_stake_authority, + user_token_account, + token_amount, + ), + ] +} + +/// Creates a `WithdrawStake` instruction. +pub fn withdraw_stake( + program_id: &Pubkey, + pool_address: &Pubkey, + user_stake_account: &Pubkey, + user_stake_authority: &Pubkey, + user_token_account: &Pubkey, + token_amount: u64, +) -> Instruction { + let data = borsh::to_vec(&SinglePoolInstruction::WithdrawStake { + user_stake_authority: *user_stake_authority, + token_amount, + }) + .unwrap(); + + let accounts = vec![ + AccountMeta::new_readonly(*pool_address, false), + AccountMeta::new(find_pool_stake_address(program_id, pool_address), false), + AccountMeta::new(find_pool_mint_address(program_id, pool_address), false), + AccountMeta::new_readonly( + find_pool_stake_authority_address(program_id, pool_address), + false, + ), + AccountMeta::new_readonly( + find_pool_mint_authority_address(program_id, pool_address), + false, + ), + AccountMeta::new(*user_stake_account, false), + AccountMeta::new(*user_token_account, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + AccountMeta::new_readonly(stake::program::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates necessary instructions to create and delegate a new stake account to +/// a given validator. Uses a fixed address for each wallet and vote account +/// combination to make it easier to find for deposits. This is an optional +/// helper function; deposits can come from any owned stake account without +/// lockup. +pub fn create_and_delegate_user_stake( + program_id: &Pubkey, + vote_account_address: &Pubkey, + user_wallet: &Pubkey, + rent: &Rent, + stake_amount: u64, +) -> Vec { + let pool_address = find_pool_address(program_id, vote_account_address); + let stake_space = std::mem::size_of::(); + let lamports = rent + .minimum_balance(stake_space) + .saturating_add(stake_amount); + let (deposit_address, deposit_seed) = + find_default_deposit_account_address_and_seed(&pool_address, user_wallet); + + stake::instruction::create_account_with_seed_and_delegate_stake( + user_wallet, + &deposit_address, + user_wallet, + &deposit_seed, + vote_account_address, + &stake::state::Authorized::auto(user_wallet), + &stake::state::Lockup::default(), + lamports, + ) +} + +/// Creates a `CreateTokenMetadata` instruction. +pub fn create_token_metadata( + program_id: &Pubkey, + pool_address: &Pubkey, + payer: &Pubkey, +) -> Instruction { + let pool_mint = find_pool_mint_address(program_id, pool_address); + let (token_metadata, _) = find_metadata_account(&pool_mint); + let data = borsh::to_vec(&SinglePoolInstruction::CreateTokenMetadata).unwrap(); + + let accounts = vec![ + AccountMeta::new_readonly(*pool_address, false), + AccountMeta::new_readonly(pool_mint, false), + AccountMeta::new_readonly( + find_pool_mint_authority_address(program_id, pool_address), + false, + ), + AccountMeta::new_readonly( + find_pool_mpl_authority_address(program_id, pool_address), + 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, + } +} + +/// Creates an `UpdateTokenMetadata` instruction. +pub fn update_token_metadata( + program_id: &Pubkey, + vote_account_address: &Pubkey, + authorized_withdrawer: &Pubkey, + name: String, + symbol: String, + uri: String, +) -> Instruction { + let pool_address = find_pool_address(program_id, vote_account_address); + let pool_mint = find_pool_mint_address(program_id, &pool_address); + let (token_metadata, _) = find_metadata_account(&pool_mint); + let data = + borsh::to_vec(&SinglePoolInstruction::UpdateTokenMetadata { name, symbol, uri }).unwrap(); + + let accounts = vec![ + AccountMeta::new_readonly(*vote_account_address, false), + AccountMeta::new_readonly(pool_address, false), + AccountMeta::new_readonly( + find_pool_mpl_authority_address(program_id, &pool_address), + false, + ), + AccountMeta::new_readonly(*authorized_withdrawer, true), + AccountMeta::new(token_metadata, false), + AccountMeta::new_readonly(inline_mpl_token_metadata::id(), false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} diff --git a/program/src/lib.rs b/program/src/lib.rs new file mode 100644 index 00000000..62ed4376 --- /dev/null +++ b/program/src/lib.rs @@ -0,0 +1,125 @@ +#![deny(missing_docs)] + +//! A program for liquid staking with a single validator + +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 solana_program::{pubkey::Pubkey, stake}; + +solana_program::declare_id!("SVSPxpvHdN29nkVg9rPapPNDddN5DipNLRUFhyjFThE"); + +const POOL_PREFIX: &[u8] = b"pool"; +const POOL_STAKE_PREFIX: &[u8] = b"stake"; +const POOL_MINT_PREFIX: &[u8] = b"mint"; +const POOL_MINT_AUTHORITY_PREFIX: &[u8] = b"mint_authority"; +const POOL_STAKE_AUTHORITY_PREFIX: &[u8] = b"stake_authority"; +const POOL_MPL_AUTHORITY_PREFIX: &[u8] = b"mpl_authority"; + +const MINT_DECIMALS: u8 = 9; + +const VOTE_STATE_DISCRIMINATOR_END: usize = 4; +const VOTE_STATE_AUTHORIZED_WITHDRAWER_START: usize = 36; +const VOTE_STATE_AUTHORIZED_WITHDRAWER_END: usize = 68; + +fn find_pool_address_and_bump(program_id: &Pubkey, vote_account_address: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[POOL_PREFIX, vote_account_address.as_ref()], program_id) +} + +fn find_pool_stake_address_and_bump(program_id: &Pubkey, pool_address: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[POOL_STAKE_PREFIX, pool_address.as_ref()], program_id) +} + +fn find_pool_mint_address_and_bump(program_id: &Pubkey, pool_address: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address(&[POOL_MINT_PREFIX, pool_address.as_ref()], program_id) +} + +fn find_pool_stake_authority_address_and_bump( + program_id: &Pubkey, + pool_address: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[POOL_STAKE_AUTHORITY_PREFIX, pool_address.as_ref()], + program_id, + ) +} + +fn find_pool_mint_authority_address_and_bump( + program_id: &Pubkey, + pool_address: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[POOL_MINT_AUTHORITY_PREFIX, pool_address.as_ref()], + program_id, + ) +} + +fn find_pool_mpl_authority_address_and_bump( + program_id: &Pubkey, + pool_address: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[POOL_MPL_AUTHORITY_PREFIX, pool_address.as_ref()], + program_id, + ) +} + +fn find_default_deposit_account_address_and_seed( + pool_address: &Pubkey, + user_wallet_address: &Pubkey, +) -> (Pubkey, String) { + let pool_address_str = pool_address.to_string(); + let seed = format!("svsp{}", &pool_address_str[0..28]); + let address = + Pubkey::create_with_seed(user_wallet_address, &seed, &stake::program::id()).unwrap(); + + (address, seed) +} + +/// Find the canonical pool address for a given vote account. +pub fn find_pool_address(program_id: &Pubkey, vote_account_address: &Pubkey) -> Pubkey { + find_pool_address_and_bump(program_id, vote_account_address).0 +} + +/// Find the canonical stake account address for a given pool account. +pub fn find_pool_stake_address(program_id: &Pubkey, pool_address: &Pubkey) -> Pubkey { + find_pool_stake_address_and_bump(program_id, pool_address).0 +} + +/// Find the canonical token mint address for a given pool account. +pub fn find_pool_mint_address(program_id: &Pubkey, pool_address: &Pubkey) -> Pubkey { + find_pool_mint_address_and_bump(program_id, pool_address).0 +} + +/// Find the canonical stake authority address for a given pool account. +pub fn find_pool_stake_authority_address(program_id: &Pubkey, pool_address: &Pubkey) -> Pubkey { + find_pool_stake_authority_address_and_bump(program_id, pool_address).0 +} + +/// Find the canonical mint authority address for a given pool account. +pub fn find_pool_mint_authority_address(program_id: &Pubkey, pool_address: &Pubkey) -> Pubkey { + find_pool_mint_authority_address_and_bump(program_id, pool_address).0 +} + +/// Find the canonical MPL authority address for a given pool account. +pub fn find_pool_mpl_authority_address(program_id: &Pubkey, pool_address: &Pubkey) -> Pubkey { + find_pool_mpl_authority_address_and_bump(program_id, pool_address).0 +} + +/// Find the address of the default intermediate account that holds activating +/// user stake before deposit. +pub fn find_default_deposit_account_address( + pool_address: &Pubkey, + user_wallet_address: &Pubkey, +) -> Pubkey { + find_default_deposit_account_address_and_seed(pool_address, user_wallet_address).0 +} diff --git a/program/src/processor.rs b/program/src/processor.rs new file mode 100644 index 00000000..55379d63 --- /dev/null +++ b/program/src/processor.rs @@ -0,0 +1,1539 @@ +//! program state processor + +use { + crate::{ + error::SinglePoolError, + inline_mpl_token_metadata::{ + self, + instruction::{create_metadata_accounts_v3, update_metadata_accounts_v2}, + pda::find_metadata_account, + state::DataV2, + }, + instruction::SinglePoolInstruction, + state::{SinglePool, SinglePoolAccountType}, + MINT_DECIMALS, POOL_MINT_AUTHORITY_PREFIX, POOL_MINT_PREFIX, POOL_MPL_AUTHORITY_PREFIX, + POOL_PREFIX, POOL_STAKE_AUTHORITY_PREFIX, POOL_STAKE_PREFIX, + VOTE_STATE_AUTHORIZED_WITHDRAWER_END, VOTE_STATE_AUTHORIZED_WITHDRAWER_START, + VOTE_STATE_DISCRIMINATOR_END, + }, + borsh::BorshDeserialize, + solana_program::{ + account_info::{next_account_info, AccountInfo}, + borsh1::{get_packed_len, try_from_slice_unchecked}, + entrypoint::ProgramResult, + msg, + native_token::LAMPORTS_PER_SOL, + program::invoke_signed, + program_error::ProgramError, + program_pack::Pack, + pubkey::Pubkey, + rent::Rent, + stake::{ + self, + state::{Meta, Stake, StakeStateV2}, + }, + stake_history::Epoch, + system_instruction, system_program, + sysvar::{clock::Clock, Sysvar}, + vote::program as vote_program, + }, + spl_token::state::Mint, +}; + +/// Calculate pool tokens to mint, given outstanding token supply, pool active +/// stake, and deposit active stake +fn calculate_deposit_amount( + pre_token_supply: u64, + pre_pool_stake: u64, + user_stake_to_deposit: u64, +) -> Option { + if pre_pool_stake == 0 || pre_token_supply == 0 { + Some(user_stake_to_deposit) + } else { + u64::try_from( + (user_stake_to_deposit as u128) + .checked_mul(pre_token_supply as u128)? + .checked_div(pre_pool_stake as u128)?, + ) + .ok() + } +} + +/// Calculate pool stake to return, given outstanding token supply, pool active +/// stake, and tokens to redeem +fn calculate_withdraw_amount( + pre_token_supply: u64, + pre_pool_stake: u64, + user_tokens_to_burn: u64, +) -> Option { + let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_stake as u128)?; + let denominator = pre_token_supply as u128; + if numerator < denominator || denominator == 0 { + Some(0) + } else { + u64::try_from(numerator.checked_div(denominator)?).ok() + } +} + +/// Deserialize the stake state from AccountInfo +fn get_stake_state(stake_account_info: &AccountInfo) -> Result<(Meta, Stake), ProgramError> { + let stake_state = try_from_slice_unchecked::(&stake_account_info.data.borrow())?; + + match stake_state { + StakeStateV2::Stake(meta, stake, _) => Ok((meta, stake)), + _ => Err(SinglePoolError::WrongStakeStake.into()), + } +} + +/// Deserialize the stake amount from AccountInfo +fn get_stake_amount(stake_account_info: &AccountInfo) -> Result { + Ok(get_stake_state(stake_account_info)?.1.delegation.stake) +} + +/// Determine if stake is active +fn is_stake_active_without_history(stake: &Stake, current_epoch: Epoch) -> bool { + stake.delegation.activation_epoch < current_epoch + && stake.delegation.deactivation_epoch == Epoch::MAX +} + +/// Check pool account address for the validator vote account +fn check_pool_address( + program_id: &Pubkey, + vote_account_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + vote_account_address, + check_address, + &crate::find_pool_address_and_bump, + "pool", + SinglePoolError::InvalidPoolAccount, + ) +} + +/// Check pool stake account address for the pool account +fn check_pool_stake_address( + program_id: &Pubkey, + pool_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + pool_address, + check_address, + &crate::find_pool_stake_address_and_bump, + "stake account", + SinglePoolError::InvalidPoolStakeAccount, + ) +} + +/// Check pool mint address for the pool account +fn check_pool_mint_address( + program_id: &Pubkey, + pool_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + pool_address, + check_address, + &crate::find_pool_mint_address_and_bump, + "mint", + SinglePoolError::InvalidPoolMint, + ) +} + +/// Check pool stake authority address for the pool account +fn check_pool_stake_authority_address( + program_id: &Pubkey, + pool_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + pool_address, + check_address, + &crate::find_pool_stake_authority_address_and_bump, + "stake authority", + SinglePoolError::InvalidPoolStakeAuthority, + ) +} + +/// Check pool mint authority address for the pool account +fn check_pool_mint_authority_address( + program_id: &Pubkey, + pool_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + pool_address, + check_address, + &crate::find_pool_mint_authority_address_and_bump, + "mint authority", + SinglePoolError::InvalidPoolMintAuthority, + ) +} + +/// Check pool MPL authority address for the pool account +fn check_pool_mpl_authority_address( + program_id: &Pubkey, + pool_address: &Pubkey, + check_address: &Pubkey, +) -> Result { + check_pool_pda( + program_id, + pool_address, + check_address, + &crate::find_pool_mpl_authority_address_and_bump, + "MPL authority", + SinglePoolError::InvalidPoolMplAuthority, + ) +} + +fn check_pool_pda( + program_id: &Pubkey, + base_address: &Pubkey, + check_address: &Pubkey, + pda_lookup_fn: &dyn Fn(&Pubkey, &Pubkey) -> (Pubkey, u8), + pda_name: &str, + pool_error: SinglePoolError, +) -> Result { + let (derived_address, bump_seed) = pda_lookup_fn(program_id, base_address); + if *check_address != derived_address { + msg!( + "Incorrect {} address for base {}: expected {}, received {}", + pda_name, + base_address, + derived_address, + check_address, + ); + Err(pool_error.into()) + } else { + Ok(bump_seed) + } +} + +/// Check vote account is owned by the vote program and not a legacy variant +fn check_vote_account(vote_account_info: &AccountInfo) -> Result<(), ProgramError> { + check_account_owner(vote_account_info, &vote_program::id())?; + + let vote_account_data = &vote_account_info.try_borrow_data()?; + let state_variant = vote_account_data + .get(..VOTE_STATE_DISCRIMINATOR_END) + .and_then(|s| s.try_into().ok()) + .ok_or(SinglePoolError::UnparseableVoteAccount)?; + + match u32::from_le_bytes(state_variant) { + 1 | 2 => Ok(()), + 0 => Err(SinglePoolError::LegacyVoteAccount.into()), + _ => Err(SinglePoolError::UnparseableVoteAccount.into()), + } +} + +/// 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(SinglePoolError::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 token program address +fn check_token_program(address: &Pubkey) -> Result<(), ProgramError> { + if *address != spl_token::id() { + msg!( + "Incorrect token program, expected {}, received {}", + spl_token::id(), + address + ); + 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(()) + } +} + +/// Minimum delegation to create a pool +/// We floor at 1sol to avoid over-minting tokens before the relevant feature is +/// active +fn minimum_delegation() -> Result { + Ok(std::cmp::max( + stake::tools::get_minimum_delegation()?, + LAMPORTS_PER_SOL, + )) +} + +/// Program state handler. +pub struct Processor {} +impl Processor { + #[allow(clippy::too_many_arguments)] + fn stake_merge<'a>( + pool_account_key: &Pubkey, + source_account: AccountInfo<'a>, + authority: AccountInfo<'a>, + bump_seed: u8, + destination_account: AccountInfo<'a>, + clock: AccountInfo<'a>, + stake_history: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_seeds[..]]; + + invoke_signed( + &stake::instruction::merge(destination_account.key, source_account.key, authority.key) + [0], + &[ + destination_account, + source_account, + clock, + stake_history, + authority, + ], + signers, + ) + } + + fn stake_split<'a>( + pool_account_key: &Pubkey, + stake_account: AccountInfo<'a>, + authority: AccountInfo<'a>, + bump_seed: u8, + amount: u64, + split_stake: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_seeds[..]]; + + let split_instruction = + stake::instruction::split(stake_account.key, authority.key, amount, split_stake.key); + + invoke_signed( + split_instruction.last().unwrap(), + &[stake_account, split_stake, authority], + signers, + ) + } + + #[allow(clippy::too_many_arguments)] + fn stake_authorize<'a>( + pool_account_key: &Pubkey, + stake_account: AccountInfo<'a>, + stake_authority: AccountInfo<'a>, + bump_seed: u8, + new_stake_authority: &Pubkey, + clock: AccountInfo<'a>, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_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, + ) + } + + #[allow(clippy::too_many_arguments)] + fn stake_withdraw<'a>( + pool_account_key: &Pubkey, + stake_account: AccountInfo<'a>, + stake_authority: AccountInfo<'a>, + bump_seed: u8, + destination_account: AccountInfo<'a>, + clock: AccountInfo<'a>, + stake_history: AccountInfo<'a>, + lamports: u64, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_seeds[..]]; + + let withdraw_instruction = stake::instruction::withdraw( + stake_account.key, + stake_authority.key, + destination_account.key, + lamports, + None, + ); + + invoke_signed( + &withdraw_instruction, + &[ + stake_account, + destination_account, + clock, + stake_history, + stake_authority, + ], + signers, + ) + } + + #[allow(clippy::too_many_arguments)] + fn token_mint_to<'a>( + pool_account_key: &Pubkey, + token_program: AccountInfo<'a>, + mint: AccountInfo<'a>, + destination: AccountInfo<'a>, + authority: AccountInfo<'a>, + bump_seed: u8, + amount: u64, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_MINT_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_seeds[..]]; + + let ix = spl_token::instruction::mint_to( + token_program.key, + mint.key, + destination.key, + authority.key, + &[], + amount, + )?; + + invoke_signed(&ix, &[mint, destination, authority], signers) + } + + #[allow(clippy::too_many_arguments)] + fn token_burn<'a>( + pool_account_key: &Pubkey, + token_program: AccountInfo<'a>, + burn_account: AccountInfo<'a>, + mint: AccountInfo<'a>, + authority: AccountInfo<'a>, + bump_seed: u8, + amount: u64, + ) -> Result<(), ProgramError> { + let authority_seeds = &[ + POOL_MINT_AUTHORITY_PREFIX, + pool_account_key.as_ref(), + &[bump_seed], + ]; + let signers = &[&authority_seeds[..]]; + + let ix = spl_token::instruction::burn( + token_program.key, + burn_account.key, + mint.key, + authority.key, + &[], + amount, + )?; + + invoke_signed(&ix, &[burn_account, mint, authority], signers) + } + + fn process_initialize_pool(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let vote_account_info = next_account_info(account_info_iter)?; + let pool_info = next_account_info(account_info_iter)?; + let pool_stake_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let pool_stake_authority_info = next_account_info(account_info_iter)?; + let pool_mint_authority_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 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 token_program_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_vote_account(vote_account_info)?; + let pool_bump_seed = check_pool_address(program_id, vote_account_info.key, pool_info.key)?; + let stake_bump_seed = + check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; + let mint_bump_seed = + check_pool_mint_address(program_id, pool_info.key, pool_mint_info.key)?; + let stake_authority_bump_seed = check_pool_stake_authority_address( + program_id, + pool_info.key, + pool_stake_authority_info.key, + )?; + let mint_authority_bump_seed = check_pool_mint_authority_address( + program_id, + pool_info.key, + pool_mint_authority_info.key, + )?; + check_system_program(system_program_info.key)?; + check_token_program(token_program_info.key)?; + check_stake_program(stake_program_info.key)?; + + let pool_seeds = &[ + POOL_PREFIX, + vote_account_info.key.as_ref(), + &[pool_bump_seed], + ]; + let pool_signers = &[&pool_seeds[..]]; + + let stake_seeds = &[ + POOL_STAKE_PREFIX, + pool_info.key.as_ref(), + &[stake_bump_seed], + ]; + let stake_signers = &[&stake_seeds[..]]; + + let mint_seeds = &[POOL_MINT_PREFIX, pool_info.key.as_ref(), &[mint_bump_seed]]; + let mint_signers = &[&mint_seeds[..]]; + + let stake_authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[stake_authority_bump_seed], + ]; + let stake_authority_signers = &[&stake_authority_seeds[..]]; + + let mint_authority_seeds = &[ + POOL_MINT_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[mint_authority_bump_seed], + ]; + let mint_authority_signers = &[&mint_authority_seeds[..]]; + + // create the pool. user has already transferred in rent + let pool_space = get_packed_len::(); + if !rent.is_exempt(pool_info.lamports(), pool_space) { + return Err(SinglePoolError::WrongRentAmount.into()); + } + if pool_info.data_len() != 0 { + return Err(SinglePoolError::PoolAlreadyInitialized.into()); + } + + invoke_signed( + &system_instruction::allocate(pool_info.key, pool_space as u64), + &[pool_info.clone()], + pool_signers, + )?; + + invoke_signed( + &system_instruction::assign(pool_info.key, program_id), + &[pool_info.clone()], + pool_signers, + )?; + + let mut pool = try_from_slice_unchecked::(&pool_info.data.borrow())?; + pool.account_type = SinglePoolAccountType::Pool; + pool.vote_account_address = *vote_account_info.key; + borsh::to_writer(&mut pool_info.data.borrow_mut()[..], &pool)?; + + // create the pool mint. user has already transferred in rent + let mint_space = spl_token::state::Mint::LEN; + + invoke_signed( + &system_instruction::allocate(pool_mint_info.key, mint_space as u64), + &[pool_mint_info.clone()], + mint_signers, + )?; + + invoke_signed( + &system_instruction::assign(pool_mint_info.key, token_program_info.key), + &[pool_mint_info.clone()], + mint_signers, + )?; + + invoke_signed( + &spl_token::instruction::initialize_mint2( + token_program_info.key, + pool_mint_info.key, + pool_mint_authority_info.key, + None, + MINT_DECIMALS, + )?, + &[pool_mint_info.clone()], + mint_authority_signers, + )?; + + // create the pool stake account. user has already transferred in rent plus at + // least the minimum + let minimum_delegation = minimum_delegation()?; + let stake_space = std::mem::size_of::(); + let stake_rent_plus_initial = rent + .minimum_balance(stake_space) + .saturating_add(minimum_delegation); + + if pool_stake_info.lamports() < stake_rent_plus_initial { + return Err(SinglePoolError::WrongRentAmount.into()); + } + + let authorized = stake::state::Authorized::auto(pool_stake_authority_info.key); + + invoke_signed( + &system_instruction::allocate(pool_stake_info.key, stake_space as u64), + &[pool_stake_info.clone()], + stake_signers, + )?; + + invoke_signed( + &system_instruction::assign(pool_stake_info.key, stake_program_info.key), + &[pool_stake_info.clone()], + stake_signers, + )?; + + invoke_signed( + &stake::instruction::initialize_checked(pool_stake_info.key, &authorized), + &[ + pool_stake_info.clone(), + rent_info.clone(), + pool_stake_authority_info.clone(), + pool_stake_authority_info.clone(), + ], + stake_authority_signers, + )?; + + // delegate stake so it activates + invoke_signed( + &stake::instruction::delegate_stake( + pool_stake_info.key, + pool_stake_authority_info.key, + vote_account_info.key, + ), + &[ + pool_stake_info.clone(), + vote_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + pool_stake_authority_info.clone(), + ], + stake_authority_signers, + )?; + + Ok(()) + } + + fn process_reactivate_pool_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let vote_account_info = next_account_info(account_info_iter)?; + let pool_info = next_account_info(account_info_iter)?; + let pool_stake_info = next_account_info(account_info_iter)?; + let pool_stake_authority_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_config_info = next_account_info(account_info_iter)?; + let stake_program_info = next_account_info(account_info_iter)?; + + check_vote_account(vote_account_info)?; + check_pool_address(program_id, vote_account_info.key, pool_info.key)?; + + SinglePool::from_account_info(pool_info, program_id)?; + + check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; + let stake_authority_bump_seed = check_pool_stake_authority_address( + program_id, + pool_info.key, + pool_stake_authority_info.key, + )?; + check_stake_program(stake_program_info.key)?; + + let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; + if pool_stake_state.delegation.deactivation_epoch > clock.epoch { + return Err(SinglePoolError::WrongStakeStake.into()); + } + + let stake_authority_seeds = &[ + POOL_STAKE_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[stake_authority_bump_seed], + ]; + let stake_authority_signers = &[&stake_authority_seeds[..]]; + + // delegate stake so it activates + invoke_signed( + &stake::instruction::delegate_stake( + pool_stake_info.key, + pool_stake_authority_info.key, + vote_account_info.key, + ), + &[ + pool_stake_info.clone(), + vote_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + stake_config_info.clone(), + pool_stake_authority_info.clone(), + ], + stake_authority_signers, + )?; + + Ok(()) + } + + fn process_deposit_stake(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let pool_info = next_account_info(account_info_iter)?; + let pool_stake_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let pool_stake_authority_info = next_account_info(account_info_iter)?; + let pool_mint_authority_info = next_account_info(account_info_iter)?; + let user_stake_info = next_account_info(account_info_iter)?; + let user_token_account_info = next_account_info(account_info_iter)?; + let user_lamport_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_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)?; + + SinglePool::from_account_info(pool_info, program_id)?; + + check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; + let stake_authority_bump_seed = check_pool_stake_authority_address( + program_id, + pool_info.key, + pool_stake_authority_info.key, + )?; + let mint_authority_bump_seed = check_pool_mint_authority_address( + program_id, + pool_info.key, + pool_mint_authority_info.key, + )?; + check_pool_mint_address(program_id, pool_info.key, pool_mint_info.key)?; + check_token_program(token_program_info.key)?; + check_stake_program(stake_program_info.key)?; + + if pool_stake_info.key == user_stake_info.key { + return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into()); + } + + let minimum_delegation = minimum_delegation()?; + + let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; + let pre_pool_stake = pool_stake_state + .delegation + .stake + .saturating_sub(minimum_delegation); + msg!("Available stake pre merge {}", pre_pool_stake); + + // user can deposit active stake into an active pool or inactive stake into an + // activating pool + let (user_stake_meta, user_stake_state) = get_stake_state(user_stake_info)?; + if user_stake_meta.authorized + != stake::state::Authorized::auto(pool_stake_authority_info.key) + || is_stake_active_without_history(&pool_stake_state, clock.epoch) + != is_stake_active_without_history(&user_stake_state, clock.epoch) + { + return Err(SinglePoolError::WrongStakeStake.into()); + } + + // merge the user stake account, which is preauthed to us, into the pool stake + // account this merge succeeding implicitly validates authority/lockup + // of the user stake account + Self::stake_merge( + pool_info.key, + user_stake_info.clone(), + pool_stake_authority_info.clone(), + stake_authority_bump_seed, + pool_stake_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + )?; + + let (pool_stake_meta, pool_stake_state) = get_stake_state(pool_stake_info)?; + let post_pool_stake = pool_stake_state + .delegation + .stake + .saturating_sub(minimum_delegation); + let post_pool_lamports = pool_stake_info.lamports(); + msg!("Available stake post merge {}", post_pool_stake); + + // stake lamports added, as a stake difference + let stake_added = post_pool_stake + .checked_sub(pre_pool_stake) + .ok_or(SinglePoolError::ArithmeticOverflow)?; + + // we calculate absolute rather than relative to deposit amount to allow + // claiming lamports mistakenly transferred in + let excess_lamports = post_pool_lamports + .checked_sub(pool_stake_state.delegation.stake) + .and_then(|amount| amount.checked_sub(pool_stake_meta.rent_exempt_reserve)) + .ok_or(SinglePoolError::ArithmeticOverflow)?; + + // sanity check: the user stake account is empty + if user_stake_info.lamports() != 0 { + return Err(SinglePoolError::UnexpectedMathError.into()); + } + + let token_supply = { + let pool_mint_data = pool_mint_info.try_borrow_data()?; + let pool_mint = Mint::unpack_from_slice(&pool_mint_data)?; + pool_mint.supply + }; + + // deposit amount is determined off stake because we return excess rent + let new_pool_tokens = calculate_deposit_amount(token_supply, pre_pool_stake, stake_added) + .ok_or(SinglePoolError::UnexpectedMathError)?; + + if new_pool_tokens == 0 { + return Err(SinglePoolError::DepositTooSmall.into()); + } + + // mint tokens to the user corresponding to their stake deposit + Self::token_mint_to( + pool_info.key, + token_program_info.clone(), + pool_mint_info.clone(), + user_token_account_info.clone(), + pool_mint_authority_info.clone(), + mint_authority_bump_seed, + new_pool_tokens, + )?; + + // return the lamports their stake account previously held for rent-exemption + if excess_lamports > 0 { + Self::stake_withdraw( + pool_info.key, + pool_stake_info.clone(), + pool_stake_authority_info.clone(), + stake_authority_bump_seed, + user_lamport_account_info.clone(), + clock_info.clone(), + stake_history_info.clone(), + excess_lamports, + )?; + } + + Ok(()) + } + + fn process_withdraw_stake( + program_id: &Pubkey, + accounts: &[AccountInfo], + user_stake_authority: &Pubkey, + token_amount: u64, + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let pool_info = next_account_info(account_info_iter)?; + let pool_stake_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let pool_stake_authority_info = next_account_info(account_info_iter)?; + let pool_mint_authority_info = next_account_info(account_info_iter)?; + let user_stake_info = next_account_info(account_info_iter)?; + let user_token_account_info = next_account_info(account_info_iter)?; + let clock_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)?; + + SinglePool::from_account_info(pool_info, program_id)?; + + check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?; + let stake_authority_bump_seed = check_pool_stake_authority_address( + program_id, + pool_info.key, + pool_stake_authority_info.key, + )?; + let mint_authority_bump_seed = check_pool_mint_authority_address( + program_id, + pool_info.key, + pool_mint_authority_info.key, + )?; + check_pool_mint_address(program_id, pool_info.key, pool_mint_info.key)?; + check_token_program(token_program_info.key)?; + check_stake_program(stake_program_info.key)?; + + if pool_stake_info.key == user_stake_info.key { + return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into()); + } + + let minimum_delegation = minimum_delegation()?; + + let pre_pool_stake = get_stake_amount(pool_stake_info)?.saturating_sub(minimum_delegation); + msg!("Available stake pre split {}", pre_pool_stake); + + let token_supply = { + let pool_mint_data = pool_mint_info.try_borrow_data()?; + let pool_mint = Mint::unpack_from_slice(&pool_mint_data)?; + pool_mint.supply + }; + + // withdraw amount is determined off stake just like deposit amount + let withdraw_stake = calculate_withdraw_amount(token_supply, pre_pool_stake, token_amount) + .ok_or(SinglePoolError::UnexpectedMathError)?; + + if withdraw_stake == 0 { + return Err(SinglePoolError::WithdrawalTooSmall.into()); + } + + // the second case should never be true, but its best to be sure + if withdraw_stake > pre_pool_stake || withdraw_stake == pool_stake_info.lamports() { + return Err(SinglePoolError::WithdrawalTooLarge.into()); + } + + // burn user tokens corresponding to the amount of stake they wish to withdraw + Self::token_burn( + pool_info.key, + token_program_info.clone(), + user_token_account_info.clone(), + pool_mint_info.clone(), + pool_mint_authority_info.clone(), + mint_authority_bump_seed, + token_amount, + )?; + + // split stake into a blank stake account the user has created for this purpose + Self::stake_split( + pool_info.key, + pool_stake_info.clone(), + pool_stake_authority_info.clone(), + stake_authority_bump_seed, + withdraw_stake, + user_stake_info.clone(), + )?; + + // assign both authorities on the new stake account to the user + Self::stake_authorize( + pool_info.key, + user_stake_info.clone(), + pool_stake_authority_info.clone(), + stake_authority_bump_seed, + user_stake_authority, + clock_info.clone(), + )?; + + let post_pool_stake = get_stake_amount(pool_stake_info)?.saturating_sub(minimum_delegation); + msg!("Available stake post split {}", post_pool_stake); + + Ok(()) + } + + fn process_create_pool_token_metadata( + program_id: &Pubkey, + accounts: &[AccountInfo], + ) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let pool_info = next_account_info(account_info_iter)?; + let pool_mint_info = next_account_info(account_info_iter)?; + let pool_mint_authority_info = next_account_info(account_info_iter)?; + let pool_mpl_authority_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)?; + + let pool = SinglePool::from_account_info(pool_info, program_id)?; + + let mint_authority_bump_seed = check_pool_mint_authority_address( + program_id, + pool_info.key, + pool_mint_authority_info.key, + )?; + let mpl_authority_bump_seed = check_pool_mpl_authority_address( + program_id, + pool_info.key, + pool_mpl_authority_info.key, + )?; + check_pool_mint_address(program_id, pool_info.key, pool_mint_info.key)?; + check_system_program(system_program_info.key)?; + check_account_owner(payer_info, &system_program::id())?; + check_mpl_metadata_program(mpl_token_metadata_program_info.key)?; + check_mpl_metadata_account_address(metadata_info.key, pool_mint_info.key)?; + + if !payer_info.is_signer { + msg!("Payer did not sign metadata creation"); + return Err(SinglePoolError::SignatureMissing.into()); + } + + let vote_address_str = pool.vote_account_address.to_string(); + let token_name = format!("SPL Single Pool {}", &vote_address_str[0..15]); + let token_symbol = format!("st{}", &vote_address_str[0..7]); + + let new_metadata_instruction = create_metadata_accounts_v3( + *mpl_token_metadata_program_info.key, + *metadata_info.key, + *pool_mint_info.key, + *pool_mint_authority_info.key, + *payer_info.key, + *pool_mpl_authority_info.key, + token_name, + token_symbol, + "".to_string(), + ); + + let mint_authority_seeds = &[ + POOL_MINT_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[mint_authority_bump_seed], + ]; + let mpl_authority_seeds = &[ + POOL_MPL_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[mpl_authority_bump_seed], + ]; + let signers = &[&mint_authority_seeds[..], &mpl_authority_seeds[..]]; + + invoke_signed( + &new_metadata_instruction, + &[ + metadata_info.clone(), + pool_mint_info.clone(), + pool_mint_authority_info.clone(), + payer_info.clone(), + pool_mpl_authority_info.clone(), + system_program_info.clone(), + ], + signers, + )?; + + Ok(()) + } + + 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 vote_account_info = next_account_info(account_info_iter)?; + let pool_info = next_account_info(account_info_iter)?; + let pool_mpl_authority_info = next_account_info(account_info_iter)?; + let authorized_withdrawer_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_vote_account(vote_account_info)?; + check_pool_address(program_id, vote_account_info.key, pool_info.key)?; + + let pool = SinglePool::from_account_info(pool_info, program_id)?; + if pool.vote_account_address != *vote_account_info.key { + return Err(SinglePoolError::InvalidPoolAccount.into()); + } + + let mpl_authority_bump_seed = check_pool_mpl_authority_address( + program_id, + pool_info.key, + pool_mpl_authority_info.key, + )?; + let pool_mint_address = crate::find_pool_mint_address(program_id, pool_info.key); + check_mpl_metadata_program(mpl_token_metadata_program_info.key)?; + check_mpl_metadata_account_address(metadata_info.key, &pool_mint_address)?; + + // we use authorized_withdrawer to authenticate the caller controls the vote + // account this is safer than using an authorized_voter since those keys + // live hot and validator-operators we spoke with indicated this would + // be their preference as well + let vote_account_data = &vote_account_info.try_borrow_data()?; + let vote_account_withdrawer = vote_account_data + .get(VOTE_STATE_AUTHORIZED_WITHDRAWER_START..VOTE_STATE_AUTHORIZED_WITHDRAWER_END) + .and_then(|x| Pubkey::try_from(x).ok()) + .ok_or(SinglePoolError::UnparseableVoteAccount)?; + + if *authorized_withdrawer_info.key != vote_account_withdrawer { + msg!("Vote account authorized withdrawer does not match the account provided."); + return Err(SinglePoolError::InvalidMetadataSigner.into()); + } + + if !authorized_withdrawer_info.is_signer { + msg!("Vote account authorized withdrawer did not sign metadata update."); + return Err(SinglePoolError::SignatureMissing.into()); + } + + let update_metadata_accounts_instruction = update_metadata_accounts_v2( + *mpl_token_metadata_program_info.key, + *metadata_info.key, + *pool_mpl_authority_info.key, + None, + Some(DataV2 { + name, + symbol, + uri, + seller_fee_basis_points: 0, + creators: None, + collection: None, + uses: None, + }), + None, + Some(true), + ); + + let mpl_authority_seeds = &[ + POOL_MPL_AUTHORITY_PREFIX, + pool_info.key.as_ref(), + &[mpl_authority_bump_seed], + ]; + let signers = &[&mpl_authority_seeds[..]]; + + invoke_signed( + &update_metadata_accounts_instruction, + &[metadata_info.clone(), pool_mpl_authority_info.clone()], + signers, + )?; + + Ok(()) + } + + /// Processes [Instruction](enum.Instruction.html). + pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = SinglePoolInstruction::try_from_slice(input)?; + match instruction { + SinglePoolInstruction::InitializePool => { + msg!("Instruction: InitializePool"); + Self::process_initialize_pool(program_id, accounts) + } + SinglePoolInstruction::ReactivatePoolStake => { + msg!("Instruction: ReactivatePoolStake"); + Self::process_reactivate_pool_stake(program_id, accounts) + } + SinglePoolInstruction::DepositStake => { + msg!("Instruction: DepositStake"); + Self::process_deposit_stake(program_id, accounts) + } + SinglePoolInstruction::WithdrawStake { + user_stake_authority, + token_amount, + } => { + msg!("Instruction: WithdrawStake"); + Self::process_withdraw_stake( + program_id, + accounts, + &user_stake_authority, + token_amount, + ) + } + SinglePoolInstruction::CreateTokenMetadata => { + msg!("Instruction: CreateTokenMetadata"); + Self::process_create_pool_token_metadata(program_id, accounts) + } + SinglePoolInstruction::UpdateTokenMetadata { name, symbol, uri } => { + msg!("Instruction: UpdateTokenMetadata"); + Self::process_update_pool_token_metadata(program_id, accounts, name, symbol, uri) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::arithmetic_side_effects)] +mod tests { + use { + super::*, + approx::assert_relative_eq, + rand::{ + distributions::{Distribution, Uniform}, + rngs::StdRng, + seq::{IteratorRandom, SliceRandom}, + Rng, SeedableRng, + }, + std::collections::BTreeMap, + test_case::test_case, + }; + + // approximately 6%/yr assuking 146 epochs + const INFLATION_BASE_RATE: f64 = 0.0004; + + #[derive(Clone, Debug, Default)] + struct PoolState { + pub token_supply: u64, + pub total_stake: u64, + pub user_token_balances: BTreeMap, + } + impl PoolState { + // deposits a given amount of stake and returns the equivalent tokens on success + // note this is written as unsugared do-notation, so *any* failure returns None + // otherwise returns the value produced by its respective calculate function + #[rustfmt::skip] + pub fn deposit(&mut self, user_pubkey: &Pubkey, stake_to_deposit: u64) -> Option { + calculate_deposit_amount(self.token_supply, self.total_stake, stake_to_deposit) + .and_then(|tokens_to_mint| self.token_supply.checked_add(tokens_to_mint) + .and_then(|new_token_supply| self.total_stake.checked_add(stake_to_deposit) + .and_then(|new_total_stake| self.user_token_balances.remove(user_pubkey).or(Some(0)) + .and_then(|old_user_token_balance| old_user_token_balance.checked_add(tokens_to_mint) + .map(|new_user_token_balance| { + self.token_supply = new_token_supply; + self.total_stake = new_total_stake; + let _ = self.user_token_balances.insert(*user_pubkey, new_user_token_balance); + tokens_to_mint + }))))) + } + + // burns a given amount of tokens and returns the equivalent stake on success + // note this is written as unsugared do-notation, so *any* failure returns None + // otherwise returns the value produced by its respective calculate function + #[rustfmt::skip] + pub fn withdraw(&mut self, user_pubkey: &Pubkey, tokens_to_burn: u64) -> Option { + calculate_withdraw_amount(self.token_supply, self.total_stake, tokens_to_burn) + .and_then(|stake_to_withdraw| self.token_supply.checked_sub(tokens_to_burn) + .and_then(|new_token_supply| self.total_stake.checked_sub(stake_to_withdraw) + .and_then(|new_total_stake| self.user_token_balances.remove(user_pubkey) + .and_then(|old_user_token_balance| old_user_token_balance.checked_sub(tokens_to_burn) + .map(|new_user_token_balance| { + self.token_supply = new_token_supply; + self.total_stake = new_total_stake; + let _ = self.user_token_balances.insert(*user_pubkey, new_user_token_balance); + stake_to_withdraw + }))))) + } + + // adds an arbitrary amount of stake, as if inflation rewards were granted + pub fn reward(&mut self, reward_amount: u64) { + self.total_stake = self.total_stake.checked_add(reward_amount).unwrap(); + } + + // get the token balance for a user + pub fn tokens(&self, user_pubkey: &Pubkey) -> u64 { + *self.user_token_balances.get(user_pubkey).unwrap_or(&0) + } + + // get the amount of stake that belongs to a user + pub fn stake(&self, user_pubkey: &Pubkey) -> u64 { + let tokens = self.tokens(user_pubkey); + if tokens > 0 { + u64::try_from(tokens as u128 * self.total_stake as u128 / self.token_supply as u128) + .unwrap() + } else { + 0 + } + } + + // get the share of the pool that belongs to a user, as a float between 0 and 1 + pub fn share(&self, user_pubkey: &Pubkey) -> f64 { + let tokens = self.tokens(user_pubkey); + if tokens > 0 { + tokens as f64 / self.token_supply as f64 + } else { + 0.0 + } + } + } + + // this deterministically tests basic behavior of calculate_deposit_amount and + // calculate_withdraw_amount + #[test] + fn simple_deposit_withdraw() { + let mut pool = PoolState::default(); + let alice = Pubkey::new_unique(); + let bob = Pubkey::new_unique(); + let chad = Pubkey::new_unique(); + + // first deposit. alice now has 250 + pool.deposit(&alice, 250).unwrap(); + assert_eq!(pool.tokens(&alice), 250); + assert_eq!(pool.token_supply, 250); + assert_eq!(pool.total_stake, 250); + + // second deposit. bob now has 750 + pool.deposit(&bob, 750).unwrap(); + assert_eq!(pool.tokens(&bob), 750); + assert_eq!(pool.token_supply, 1000); + assert_eq!(pool.total_stake, 1000); + + // alice controls 25% of the pool and bob controls 75%. rewards should accrue + // likewise use nice even numbers, we can test fiddly stuff in the + // stochastic cases + assert_relative_eq!(pool.share(&alice), 0.25); + assert_relative_eq!(pool.share(&bob), 0.75); + pool.reward(1000); + assert_eq!(pool.stake(&alice), pool.tokens(&alice) * 2); + assert_eq!(pool.stake(&bob), pool.tokens(&bob) * 2); + assert_relative_eq!(pool.share(&alice), 0.25); + assert_relative_eq!(pool.share(&bob), 0.75); + + // alice harvests rewards, reducing her share of the *previous* pool size to + // 12.5% but because the pool itself has shrunk to 87.5%, its actually + // more like 14.3% luckily chad deposits immediately after to make our + // math easier + let stake_removed = pool.withdraw(&alice, 125).unwrap(); + pool.deposit(&chad, 250).unwrap(); + assert_eq!(stake_removed, 250); + assert_relative_eq!(pool.share(&alice), 0.125); + assert_relative_eq!(pool.share(&bob), 0.75); + + // bob and chad exit the pool + let stake_removed = pool.withdraw(&bob, 750).unwrap(); + assert_eq!(stake_removed, 1500); + assert_relative_eq!(pool.share(&bob), 0.0); + pool.withdraw(&chad, 125).unwrap(); + assert_relative_eq!(pool.share(&alice), 1.0); + } + + // this stochastically tests calculate_deposit_amount and + // calculate_withdraw_amount the objective is specifically to ensure that + // the math does not fail on any combination of state changes the no_minimum + // case is to account for a future where small deposits are possible through + // multistake + #[test_case(rand::random(), false, false; "no_rewards")] + #[test_case(rand::random(), true, false; "with_rewards")] + #[test_case(rand::random(), true, true; "no_minimum")] + fn random_deposit_withdraw(seed: u64, with_rewards: bool, no_minimum: bool) { + println!( + "TEST SEED: {}. edit the test case to pass this value if needed to debug failures", + seed + ); + let mut prng = rand::rngs::StdRng::seed_from_u64(seed); + + // deposit_range is the range of typical deposits within minimum_delegation + // minnow_range is under the minimum for cases where we test that + // op_range is how we roll whether to deposit, withdraw, or reward + // std_range is a standard probability + let deposit_range = Uniform::from(LAMPORTS_PER_SOL..LAMPORTS_PER_SOL * 1000); + let minnow_range = Uniform::from(1..LAMPORTS_PER_SOL); + let op_range = Uniform::from(if with_rewards { 0.0..1.0 } else { 0.0..0.65 }); + let std_range = Uniform::from(0.0..1.0); + + let deposit_amount = |prng: &mut StdRng| { + if no_minimum && prng.gen_bool(0.2) { + minnow_range.sample(prng) + } else { + deposit_range.sample(prng) + } + }; + + // run everything a number of times to get a good sample + for _ in 0..100 { + // PoolState tracks all outstanding tokens and the total combined stake + // there is no reasonable way to track "deposited stake" because reward accrual + // makes this concept incoherent a token corresponds to a + // percentage, not a stake value + let mut pool = PoolState::default(); + + // generate between 1 and 100 users and have ~half of them deposit + // note for most of these tests we adhere to the minimum delegation + // one of the thing we want to see is deposit size being many ooms larger than + // reward size + let mut users = vec![]; + let user_count: usize = prng.gen_range(1..=100); + for _ in 0..user_count { + let user = Pubkey::new_unique(); + + if prng.gen_bool(0.5) { + pool.deposit(&user, deposit_amount(&mut prng)).unwrap(); + } + + users.push(user); + } + + // now we do a set of arbitrary operations and confirm invariants hold + // we underweight withdraw a little bit to lessen the chances we random walk to + // an empty pool + for _ in 0..1000 { + match op_range.sample(&mut prng) { + // deposit a random amount of stake for tokens with a random user + // check their stake, tokens, and share increase by the expected amount + n if n <= 0.35 => { + let user = users.choose(&mut prng).unwrap(); + let prev_share = pool.share(user); + let prev_stake = pool.stake(user); + let prev_token_supply = pool.token_supply; + let prev_total_stake = pool.total_stake; + + let stake_deposited = deposit_amount(&mut prng); + let tokens_minted = pool.deposit(user, stake_deposited).unwrap(); + + // stake increased by exactly the deposit amount + assert_eq!(pool.total_stake - prev_total_stake, stake_deposited); + + // calculated stake fraction is within 2 lamps of deposit amount + assert!( + (pool.stake(user) as i64 - prev_stake as i64 - stake_deposited as i64) + .abs() + <= 2 + ); + + // tokens increased by exactly the mint amount + assert_eq!(pool.token_supply - prev_token_supply, tokens_minted); + + // tokens per supply increased with stake per total + if prev_total_stake > 0 { + assert_relative_eq!( + pool.share(user) - prev_share, + pool.stake(user) as f64 / pool.total_stake as f64 + - prev_stake as f64 / prev_total_stake as f64, + epsilon = 1e-6 + ); + } + } + + // burn a random amount of tokens from a random user with outstanding deposits + // check their stake, tokens, and share decrease by the expected amount + n if n > 0.35 && n <= 0.65 => { + if let Some(user) = users + .iter() + .filter(|user| pool.tokens(user) > 0) + .choose(&mut prng) + { + let prev_tokens = pool.tokens(user); + let prev_share = pool.share(user); + let prev_stake = pool.stake(user); + let prev_token_supply = pool.token_supply; + let prev_total_stake = pool.total_stake; + + let tokens_burned = if std_range.sample(&mut prng) <= 0.1 { + prev_tokens + } else { + prng.gen_range(0..prev_tokens) + }; + let stake_received = pool.withdraw(user, tokens_burned).unwrap(); + + // stake decreased by exactly the withdraw amount + assert_eq!(prev_total_stake - pool.total_stake, stake_received); + + // calculated stake fraction is within 2 lamps of withdraw amount + assert!( + (prev_stake as i64 + - pool.stake(user) as i64 + - stake_received as i64) + .abs() + <= 2 + ); + + // tokens decreased by the burn amount + assert_eq!(prev_token_supply - pool.token_supply, tokens_burned); + + // tokens per supply decreased with stake per total + if pool.total_stake > 0 { + assert_relative_eq!( + prev_share - pool.share(user), + prev_stake as f64 / prev_total_stake as f64 + - pool.stake(user) as f64 / pool.total_stake as f64, + epsilon = 1e-6 + ); + } + }; + } + + // run a single epoch worth of rewards + // check all user shares stay the same and stakes increase by the expected + // amount + _ => { + assert!(with_rewards); + + let prev_shares_stakes = users + .iter() + .map(|user| (user, pool.share(user), pool.stake(user))) + .filter(|(_, _, stake)| stake > &0) + .collect::>(); + + pool.reward((pool.total_stake as f64 * INFLATION_BASE_RATE) as u64); + + for (user, prev_share, prev_stake) in prev_shares_stakes { + // shares are the same before and after + assert_eq!(pool.share(user), prev_share); + + let curr_stake = pool.stake(user); + let stake_share = prev_stake as f64 * INFLATION_BASE_RATE; + let stake_diff = (curr_stake - prev_stake) as f64; + + // stake increase is within 2 lamps when calculated as a difference or a + // percentage + assert!((stake_share - stake_diff).abs() <= 2.0); + } + } + } + } + } + } +} diff --git a/program/src/state.rs b/program/src/state.rs new file mode 100644 index 00000000..0c58b87f --- /dev/null +++ b/program/src/state.rs @@ -0,0 +1,57 @@ +//! State transition types + +use { + crate::{error::SinglePoolError, find_pool_address}, + borsh::{BorshDeserialize, BorshSchema, BorshSerialize}, + solana_program::{ + account_info::AccountInfo, borsh1::try_from_slice_unchecked, program_error::ProgramError, + pubkey::Pubkey, + }, +}; + +/// Single-Validator Stake Pool account type +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub enum SinglePoolAccountType { + /// Uninitialized account + #[default] + Uninitialized, + /// Main pool account + Pool, +} + +/// Single-Validator Stake Pool account, used to derive all PDAs +#[derive(Clone, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub struct SinglePool { + /// Pool account type, reserved for future compat + pub account_type: SinglePoolAccountType, + /// The vote account this pool is mapped to + pub vote_account_address: Pubkey, +} +impl SinglePool { + /// Create a SinglePool struct from its account info + pub fn from_account_info( + account_info: &AccountInfo, + program_id: &Pubkey, + ) -> Result { + // pool is allocated and owned by this program + if account_info.data_len() == 0 || account_info.owner != program_id { + return Err(SinglePoolError::InvalidPoolAccount.into()); + } + + let pool = try_from_slice_unchecked::(&account_info.data.borrow())?; + + // pool is well-typed + if pool.account_type != SinglePoolAccountType::Pool { + return Err(SinglePoolError::InvalidPoolAccount.into()); + } + + // pool vote account address is properly configured. in practice this is + // irrefutable because the pool is initialized from the address that + // derives it, and never modified + if *account_info.key != find_pool_address(program_id, &pool.vote_account_address) { + return Err(SinglePoolError::InvalidPoolAccount.into()); + } + + Ok(pool) + } +} diff --git a/program/tests/accounts.rs b/program/tests/accounts.rs new file mode 100644 index 00000000..d6276be2 --- /dev/null +++ b/program/tests/accounts.rs @@ -0,0 +1,304 @@ +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::items_after_test_module)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + instruction::Instruction, program_error::ProgramError, pubkey::Pubkey, signature::Signer, + stake, system_program, transaction::Transaction, + }, + spl_single_pool::{ + error::SinglePoolError, + id, + instruction::{self, SinglePoolInstruction}, + }, + test_case::test_case, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TestMode { + Initialize, + Deposit, + Withdraw, +} + +// build a full transaction for initialize, deposit, and withdraw +// this is used to test knocking out individual accounts, for the sake of +// confirming the pubkeys are checked +async fn build_instructions( + context: &mut ProgramTestContext, + accounts: &SinglePoolAccounts, + test_mode: TestMode, +) -> (Vec, usize) { + let initialize_instructions = if test_mode == TestMode::Initialize { + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.validator, + &accounts.voter.pubkey(), + &accounts.withdrawer.pubkey(), + &accounts.vote_account, + ) + .await; + + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.alice.pubkey(), + USER_STARTING_LAMPORTS, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let minimum_delegation = get_pool_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + instruction::initialize( + &id(), + &accounts.vote_account.pubkey(), + &accounts.alice.pubkey(), + &rent, + minimum_delegation, + ) + } else { + accounts + .initialize_for_deposit(context, TEST_STAKE_AMOUNT, None) + .await; + advance_epoch(context).await; + + vec![] + }; + + let deposit_instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + + let withdraw_instructions = if test_mode == TestMode::Withdraw { + let transaction = Transaction::new_signed_with_payer( + &deposit_instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &accounts.alice, + &context.last_blockhash, + &accounts.alice_stake, + ) + .await; + + instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + ) + } else { + vec![] + }; + + let (instructions, i) = match test_mode { + TestMode::Initialize => (initialize_instructions, 3), + TestMode::Deposit => (deposit_instructions, 2), + TestMode::Withdraw => (withdraw_instructions, 1), + }; + + // guard against instructions moving with code changes + assert_eq!(instructions[i].program_id, id()); + + (instructions, i) +} + +// test that account addresses are checked properly +#[test_case(TestMode::Initialize; "initialize")] +#[test_case(TestMode::Deposit; "deposit")] +#[test_case(TestMode::Withdraw; "withdraw")] +#[tokio::test] +async fn fail_account_checks(test_mode: TestMode) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + let (instructions, i) = build_instructions(&mut context, &accounts, test_mode).await; + + for j in 0..instructions[i].accounts.len() { + let mut instructions = instructions.clone(); + let instruction_account = &mut instructions[i].accounts[j]; + + // wallet address can be arbitrary + if instruction_account.pubkey == accounts.alice.pubkey() { + continue; + } + + let prev_pubkey = instruction_account.pubkey; + instruction_account.pubkey = Pubkey::new_unique(); + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + // random addresses should error always otherwise + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + + // these ones we can also make sure we hit the explicit check, before we use it + if prev_pubkey == accounts.pool { + check_error(e, SinglePoolError::InvalidPoolAccount) + } else if prev_pubkey == accounts.stake_account { + check_error(e, SinglePoolError::InvalidPoolStakeAccount) + } else if prev_pubkey == accounts.stake_authority { + check_error(e, SinglePoolError::InvalidPoolStakeAuthority) + } else if prev_pubkey == accounts.mint_authority { + check_error(e, SinglePoolError::InvalidPoolMintAuthority) + } else if prev_pubkey == accounts.mpl_authority { + check_error(e, SinglePoolError::InvalidPoolMplAuthority) + } else if prev_pubkey == accounts.mint { + check_error(e, SinglePoolError::InvalidPoolMint) + } else if [system_program::id(), spl_token::id(), stake::program::id()] + .contains(&prev_pubkey) + { + check_error(e, ProgramError::IncorrectProgramId) + } + } +} + +// make an individual instruction for all program instructions +// the match is just so this will error if new instructions are added +// if you are reading this because of that error, add the case to the +// `consistent_account_order` test!!! +fn make_basic_instruction( + accounts: &SinglePoolAccounts, + instruction_type: SinglePoolInstruction, +) -> Instruction { + match instruction_type { + SinglePoolInstruction::InitializePool => { + instruction::initialize_pool(&id(), &accounts.vote_account.pubkey()) + } + SinglePoolInstruction::ReactivatePoolStake => { + instruction::reactivate_pool_stake(&id(), &accounts.vote_account.pubkey()) + } + SinglePoolInstruction::DepositStake => instruction::deposit_stake( + &id(), + &accounts.pool, + &Pubkey::default(), + &Pubkey::default(), + &Pubkey::default(), + ), + SinglePoolInstruction::WithdrawStake { .. } => instruction::withdraw_stake( + &id(), + &accounts.pool, + &Pubkey::default(), + &Pubkey::default(), + &Pubkey::default(), + 0, + ), + SinglePoolInstruction::CreateTokenMetadata => { + instruction::create_token_metadata(&id(), &accounts.pool, &Pubkey::default()) + } + SinglePoolInstruction::UpdateTokenMetadata { .. } => instruction::update_token_metadata( + &id(), + &accounts.vote_account.pubkey(), + &accounts.withdrawer.pubkey(), + "".to_string(), + "".to_string(), + "".to_string(), + ), + } +} + +// advanced technology +fn is_sorted(data: &[T]) -> bool +where + T: Ord, +{ + data.windows(2).all(|w| w[0] <= w[1]) +} + +// check that major accounts always show up in the same order, to spare +// developer confusion +#[test] +fn consistent_account_order() { + let accounts = SinglePoolAccounts::default(); + + let ordering = vec![ + accounts.vote_account.pubkey(), + accounts.pool, + accounts.stake_account, + accounts.mint, + accounts.stake_authority, + accounts.mint_authority, + accounts.mpl_authority, + ]; + + let instructions = vec![ + make_basic_instruction(&accounts, SinglePoolInstruction::InitializePool), + make_basic_instruction(&accounts, SinglePoolInstruction::ReactivatePoolStake), + make_basic_instruction(&accounts, SinglePoolInstruction::DepositStake), + make_basic_instruction( + &accounts, + SinglePoolInstruction::WithdrawStake { + user_stake_authority: Pubkey::default(), + token_amount: 0, + }, + ), + make_basic_instruction(&accounts, SinglePoolInstruction::CreateTokenMetadata), + make_basic_instruction( + &accounts, + SinglePoolInstruction::UpdateTokenMetadata { + name: "".to_string(), + symbol: "".to_string(), + uri: "".to_string(), + }, + ), + ]; + + for instruction in instructions { + let mut indexes = vec![]; + + for target in &ordering { + if let Some(i) = instruction + .accounts + .iter() + .position(|meta| meta.pubkey == *target) + { + indexes.push(i); + } + } + + assert!(is_sorted(&indexes)); + } +} diff --git a/program/tests/create_pool_token_metadata.rs b/program/tests/create_pool_token_metadata.rs new file mode 100644 index 00000000..930c0f4f --- /dev/null +++ b/program/tests/create_pool_token_metadata.rs @@ -0,0 +1,57 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + instruction::InstructionError, pubkey::Pubkey, signature::Signer, + system_instruction::SystemError, transaction::Transaction, + }, + spl_single_pool::{id, instruction}, +}; + +fn assert_metadata(vote_account: &Pubkey, metadata: &Metadata) { + let vote_address_str = vote_account.to_string(); + let name = format!("SPL Single Pool {}", &vote_address_str[0..15]); + let symbol = format!("st{}", &vote_address_str[0..7]); + + assert!(metadata.name.starts_with(&name)); + assert!(metadata.symbol.starts_with(&symbol)); +} + +#[tokio::test] +async fn success() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + let metadata = get_metadata_account(&mut context.banks_client, &accounts.mint).await; + assert_metadata(&accounts.vote_account.pubkey(), &metadata); +} + +#[tokio::test] +async fn fail_double_init() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + refresh_blockhash(&mut context).await; + + let instruction = + instruction::create_token_metadata(&id(), &accounts.pool, &context.payer.pubkey()); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error::(e, SystemError::AccountAlreadyInUse.into()); +} diff --git a/program/tests/deposit.rs b/program/tests/deposit.rs new file mode 100644 index 00000000..1fcc0523 --- /dev/null +++ b/program/tests/deposit.rs @@ -0,0 +1,477 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + signature::Signer, + signer::keypair::Keypair, + stake::state::{Authorized, Lockup}, + transaction::Transaction, + }, + spl_associated_token_account_client::address as atoken, + spl_single_pool::{ + error::SinglePoolError, find_default_deposit_account_address, id, instruction, + }, + test_case::test_case, +}; + +#[test_case(true, 0, false, false, false; "activated::minimum_disabled")] +#[test_case(true, 0, false, false, true; "activated::minimum_disabled::small")] +#[test_case(true, 0, false, true, false; "activated::minimum_enabled")] +#[test_case(false, 0, false, false, false; "activating::minimum_disabled")] +#[test_case(false, 0, false, false, true; "activating::minimum_disabled::small")] +#[test_case(false, 0, false, true, false; "activating::minimum_enabled")] +#[test_case(true, 100_000, false, false, false; "activated::extra")] +#[test_case(false, 100_000, false, false, false; "activating::extra")] +#[test_case(true, 0, true, false, false; "activated::second")] +#[test_case(false, 0, true, false, false; "activating::second")] +#[tokio::test] +async fn success( + activate: bool, + extra_lamports: u64, + prior_deposit: bool, + enable_minimum_delegation: bool, + small_deposit: bool, +) { + let mut context = program_test(enable_minimum_delegation) + .start_with_context() + .await; + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_deposit( + &mut context, + if small_deposit { 1 } else { TEST_STAKE_AMOUNT }, + if prior_deposit { + Some(TEST_STAKE_AMOUNT * 10) + } else { + None + }, + ) + .await; + + if activate { + advance_epoch(&mut context).await; + } + + if prior_deposit { + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.bob_stake.pubkey(), + &accounts.bob_token, + &accounts.bob.pubkey(), + &accounts.bob.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.bob], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + } + + let (_, alice_stake_before_deposit, stake_lamports) = + get_stake_account(&mut context.banks_client, &accounts.alice_stake.pubkey()).await; + let alice_stake_before_deposit = alice_stake_before_deposit.unwrap().delegation.stake; + + let (_, pool_stake_before, pool_lamports_before) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let pool_stake_before = pool_stake_before.unwrap().delegation.stake; + + if extra_lamports > 0 { + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.stake_account, + extra_lamports, + ) + .await; + } + + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let wallet_lamports_after_deposit = + get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + let (pool_meta_after, pool_stake_after, pool_lamports_after) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let pool_stake_after = pool_stake_after.unwrap().delegation.stake; + + // when active, the depositor gets their rent back + // but when activating, its just added to stake + let expected_deposit = if activate { + alice_stake_before_deposit + } else { + stake_lamports + }; + + // deposit stake account is closed + assert!(context + .banks_client + .get_account(accounts.alice_stake.pubkey()) + .await + .expect("get_account") + .is_none()); + + // entire stake has moved to pool + assert_eq!(pool_stake_before + expected_deposit, pool_stake_after); + + // pool only gained stake + assert_eq!(pool_lamports_after, pool_lamports_before + expected_deposit); + assert_eq!( + pool_lamports_after, + pool_stake_before + expected_deposit + pool_meta_after.rent_exempt_reserve, + ); + + // alice got her rent back if active, or everything otherwise + // and if someone sent lamports to the stake account, the next depositor gets + // them + assert_eq!( + wallet_lamports_after_deposit, + USER_STARTING_LAMPORTS - expected_deposit + extra_lamports, + ); + + // alice got tokens. no rewards have been paid so tokens correspond to stake 1:1 + assert_eq!( + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + expected_deposit, + ); +} + +#[test_case(true, false, false; "activated::minimum_disabled")] +#[test_case(true, false, true; "activated::minimum_disabled::small")] +#[test_case(true, true, false; "activated::minimum_enabled")] +#[test_case(false, false, false; "activating::minimum_disabled")] +#[test_case(false, false, true; "activating::minimum_disabled::small")] +#[test_case(false, true, false; "activating::minimum_enabled")] +#[tokio::test] +async fn success_with_seed(activate: bool, enable_minimum_delegation: bool, small_deposit: bool) { + let mut context = program_test(enable_minimum_delegation) + .start_with_context() + .await; + let accounts = SinglePoolAccounts::default(); + let rent = context.banks_client.get_rent().await.unwrap(); + let minimum_stake = accounts.initialize(&mut context).await; + let alice_default_stake = + find_default_deposit_account_address(&accounts.pool, &accounts.alice.pubkey()); + + let instructions = instruction::create_and_delegate_user_stake( + &id(), + &accounts.vote_account.pubkey(), + &accounts.alice.pubkey(), + &rent, + if small_deposit { 1 } else { minimum_stake }, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + if activate { + advance_epoch(&mut context).await; + } + + let (_, alice_stake_before_deposit, stake_lamports) = + get_stake_account(&mut context.banks_client, &alice_default_stake).await; + let alice_stake_before_deposit = alice_stake_before_deposit.unwrap().delegation.stake; + + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &alice_default_stake, + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let wallet_lamports_after_deposit = + get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + let (_, pool_stake_after, _) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let pool_stake_after = pool_stake_after.unwrap().delegation.stake; + + let expected_deposit = if activate { + alice_stake_before_deposit + } else { + stake_lamports + }; + + // deposit stake account is closed + assert!(context + .banks_client + .get_account(alice_default_stake) + .await + .expect("get_account") + .is_none()); + + // stake moved to pool + assert_eq!(minimum_stake + expected_deposit, pool_stake_after); + + // alice got her rent back if active, or everything otherwise + assert_eq!( + wallet_lamports_after_deposit, + USER_STARTING_LAMPORTS - expected_deposit + ); + + // alice got tokens. no rewards have been paid so tokens correspond to stake 1:1 + assert_eq!( + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + expected_deposit, + ); +} + +#[test_case(true; "activated")] +#[test_case(false; "activating")] +#[tokio::test] +async fn fail_uninitialized(activate: bool) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + let stake_account = Keypair::new(); + + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.validator, + &accounts.voter.pubkey(), + &accounts.withdrawer.pubkey(), + &accounts.vote_account, + ) + .await; + + let token_account = + atoken::get_associated_token_address(&context.payer.pubkey(), &accounts.mint); + + create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &context.payer, + &context.last_blockhash, + &stake_account, + &Authorized::auto(&context.payer.pubkey()), + &Lockup::default(), + TEST_STAKE_AMOUNT, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &stake_account.pubkey(), + &context.payer, + &accounts.vote_account.pubkey(), + ) + .await; + + if activate { + advance_epoch(&mut context).await; + } + + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &stake_account.pubkey(), + &token_account, + &context.payer.pubkey(), + &context.payer.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::InvalidPoolAccount); +} + +#[test_case(true, true; "activated::automorph")] +#[test_case(false, true; "activating::automorph")] +#[test_case(true, false; "activated::unauth")] +#[test_case(false, false; "activating::unauth")] +#[tokio::test] +async fn fail_bad_account(activate: bool, automorph: bool) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_deposit(&mut context, TEST_STAKE_AMOUNT, None) + .await; + + let instruction = instruction::deposit_stake( + &id(), + &accounts.pool, + &if automorph { + accounts.stake_account + } else { + accounts.alice_stake.pubkey() + }, + &accounts.alice_token, + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + if activate { + advance_epoch(&mut context).await; + } + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + + if automorph { + check_error(e, SinglePoolError::InvalidPoolStakeAccountUsage); + } else { + check_error(e, SinglePoolError::WrongStakeStake); + } +} + +#[test_case(true; "pool_active")] +#[test_case(false; "user_active")] +#[tokio::test] +async fn fail_activation_mismatch(pool_first: bool) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + + let minimum_delegation = get_pool_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.validator, + &accounts.voter.pubkey(), + &accounts.withdrawer.pubkey(), + &accounts.vote_account, + ) + .await; + + if pool_first { + accounts.initialize(&mut context).await; + advance_epoch(&mut context).await; + } + + create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &context.payer, + &context.last_blockhash, + &accounts.alice_stake, + &Authorized::auto(&accounts.alice.pubkey()), + &Lockup::default(), + minimum_delegation, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.alice_stake.pubkey(), + &accounts.alice, + &accounts.vote_account.pubkey(), + ) + .await; + + if !pool_first { + advance_epoch(&mut context).await; + accounts.initialize(&mut context).await; + } + + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WrongStakeStake); +} diff --git a/program/tests/fixtures/mpl_token_metadata.so b/program/tests/fixtures/mpl_token_metadata.so new file mode 120000 index 00000000..43816797 --- /dev/null +++ b/program/tests/fixtures/mpl_token_metadata.so @@ -0,0 +1 @@ +../../../../stake-pool/program/tests/fixtures/mpl_token_metadata.so \ No newline at end of file diff --git a/program/tests/helpers/mod.rs b/program/tests/helpers/mod.rs new file mode 100644 index 00000000..29dd1e17 --- /dev/null +++ b/program/tests/helpers/mod.rs @@ -0,0 +1,450 @@ +#![allow(dead_code)] // needed because cargo doesn't understand test usage + +use { + solana_program_test::*, + solana_sdk::{ + account::Account as SolanaAccount, + feature_set::stake_raise_minimum_delegation_to_1_sol, + hash::Hash, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + stake::state::{Authorized, Lockup}, + system_instruction, system_program, + transaction::{Transaction, TransactionError}, + }, + solana_vote_program::{ + self, vote_instruction, + vote_state::{VoteInit, VoteState}, + }, + spl_associated_token_account_client::address as atoken, + spl_single_pool::{ + find_pool_address, find_pool_mint_address, find_pool_mint_authority_address, + find_pool_mpl_authority_address, find_pool_stake_address, + find_pool_stake_authority_address, id, inline_mpl_token_metadata, instruction, + processor::Processor, + }, +}; + +pub mod token; +pub use token::*; + +pub mod stake; +pub use stake::*; + +pub const FIRST_NORMAL_EPOCH: u64 = 15; +pub const USER_STARTING_LAMPORTS: u64 = 10_000_000_000_000; // 10k sol + +pub fn program_test(enable_minimum_delegation: bool) -> ProgramTest { + let mut program_test = ProgramTest::default(); + + program_test.add_program("mpl_token_metadata", inline_mpl_token_metadata::id(), None); + program_test.add_program("spl_single_pool", id(), processor!(Processor::process)); + program_test.prefer_bpf(false); + + if !enable_minimum_delegation { + program_test.deactivate_feature(stake_raise_minimum_delegation_to_1_sol::id()); + } + + program_test +} + +#[derive(Debug, PartialEq)] +pub struct SinglePoolAccounts { + pub validator: Keypair, + pub voter: Keypair, + pub withdrawer: Keypair, + pub vote_account: Keypair, + pub pool: Pubkey, + pub stake_account: Pubkey, + pub mint: Pubkey, + pub stake_authority: Pubkey, + pub mint_authority: Pubkey, + pub mpl_authority: Pubkey, + pub alice: Keypair, + pub bob: Keypair, + pub alice_stake: Keypair, + pub bob_stake: Keypair, + pub alice_token: Pubkey, + pub bob_token: Pubkey, + pub token_program_id: Pubkey, +} +impl SinglePoolAccounts { + // does everything in initialize_for_deposit plus performs the deposit(s) and + // creates blank account(s) optionally advances to activation before the + // deposit + pub async fn initialize_for_withdraw( + &self, + context: &mut ProgramTestContext, + alice_amount: u64, + maybe_bob_amount: Option, + activate: bool, + ) -> u64 { + let minimum_delegation = self + .initialize_for_deposit(context, alice_amount, maybe_bob_amount) + .await; + + if activate { + advance_epoch(context).await; + } + + let instructions = instruction::deposit( + &id(), + &self.pool, + &self.alice_stake.pubkey(), + &self.alice_token, + &self.alice.pubkey(), + &self.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &self.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &self.alice, + &context.last_blockhash, + &self.alice_stake, + ) + .await; + + if maybe_bob_amount.is_some() { + let instructions = instruction::deposit( + &id(), + &self.pool, + &self.bob_stake.pubkey(), + &self.bob_token, + &self.bob.pubkey(), + &self.bob.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &self.bob], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + create_blank_stake_account( + &mut context.banks_client, + &context.payer, + &self.bob, + &context.last_blockhash, + &self.bob_stake, + ) + .await; + } + + minimum_delegation + } + + // does everything in initialize plus creates/delegates one or both stake + // accounts for our users note this does not advance time, so everything is + // in an activating state + pub async fn initialize_for_deposit( + &self, + context: &mut ProgramTestContext, + alice_amount: u64, + maybe_bob_amount: Option, + ) -> u64 { + let minimum_delegation = self.initialize(context).await; + + create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &self.alice, + &context.last_blockhash, + &self.alice_stake, + &Authorized::auto(&self.alice.pubkey()), + &Lockup::default(), + alice_amount, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &self.alice_stake.pubkey(), + &self.alice, + &self.vote_account.pubkey(), + ) + .await; + + if let Some(bob_amount) = maybe_bob_amount { + create_independent_stake_account( + &mut context.banks_client, + &context.payer, + &self.bob, + &context.last_blockhash, + &self.bob_stake, + &Authorized::auto(&self.bob.pubkey()), + &Lockup::default(), + bob_amount, + ) + .await; + + delegate_stake_account( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &self.bob_stake.pubkey(), + &self.bob, + &self.vote_account.pubkey(), + ) + .await; + }; + + minimum_delegation + } + + // creates a vote account and stake pool for it. also sets up two users with sol + // and token accounts note this leaves the pool in an activating state. + // caller can advance to next epoch if they please + pub async fn initialize(&self, context: &mut ProgramTestContext) -> u64 { + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &self.validator, + &self.voter.pubkey(), + &self.withdrawer.pubkey(), + &self.vote_account, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let minimum_delegation = get_pool_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let instructions = instruction::initialize( + &id(), + &self.vote_account.pubkey(), + &context.payer.pubkey(), + &rent, + minimum_delegation, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &self.alice.pubkey(), + USER_STARTING_LAMPORTS, + ) + .await; + + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &self.bob.pubkey(), + USER_STARTING_LAMPORTS, + ) + .await; + + create_ata( + &mut context.banks_client, + &context.payer, + &self.alice.pubkey(), + &context.last_blockhash, + &self.mint, + ) + .await; + + create_ata( + &mut context.banks_client, + &context.payer, + &self.bob.pubkey(), + &context.last_blockhash, + &self.mint, + ) + .await; + + minimum_delegation + } +} +impl Default for SinglePoolAccounts { + fn default() -> Self { + let vote_account = Keypair::new(); + let alice = Keypair::new(); + let bob = Keypair::new(); + let pool = find_pool_address(&id(), &vote_account.pubkey()); + let mint = find_pool_mint_address(&id(), &pool); + + Self { + validator: Keypair::new(), + voter: Keypair::new(), + withdrawer: Keypair::new(), + stake_account: find_pool_stake_address(&id(), &pool), + pool, + mint, + stake_authority: find_pool_stake_authority_address(&id(), &pool), + mint_authority: find_pool_mint_authority_address(&id(), &pool), + mpl_authority: find_pool_mpl_authority_address(&id(), &pool), + vote_account, + alice_stake: Keypair::new(), + bob_stake: Keypair::new(), + alice_token: atoken::get_associated_token_address(&alice.pubkey(), &mint), + bob_token: atoken::get_associated_token_address(&bob.pubkey(), &mint), + alice, + bob, + token_program_id: spl_token::id(), + } + } +} + +pub async fn refresh_blockhash(context: &mut ProgramTestContext) { + context.last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); +} + +pub async fn advance_epoch(context: &mut ProgramTestContext) { + let root_slot = context.banks_client.get_root_slot().await.unwrap(); + let slots_per_epoch = context.genesis_config().epoch_schedule.slots_per_epoch; + context.warp_to_slot(root_slot + slots_per_epoch).unwrap(); +} + +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") +} + +pub async fn create_vote( + banks_client: &mut BanksClient, + payer: &Keypair, + recent_blockhash: &Hash, + validator: &Keypair, + voter: &Pubkey, + withdrawer: &Pubkey, + vote_account: &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_account.pubkey(), + &VoteInit { + node_pubkey: validator.pubkey(), + authorized_voter: *voter, + authorized_withdrawer: *withdrawer, + ..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_account, payer], + *recent_blockhash, + ); + + // ignore errors for idempotency + let _ = banks_client.process_transaction(transaction).await; +} + +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(); +} + +pub fn check_error(got: BanksClientError, expected: T) +where + ProgramError: TryFrom, +{ + // banks error -> transaction error -> instruction error -> program error + let got_p: ProgramError = if let TransactionError::InstructionError(_, e) = got.unwrap() { + e.try_into().unwrap() + } else { + panic!( + "couldn't convert {:?} to ProgramError (expected {:?})", + got, expected + ); + }; + + // this silly thing is because we can guarantee From has a Debug for T + // but TryFrom produces Result and E may not have Debug. so we can't + // call unwrap also we use TryFrom because we have to go `instruction + // error-> program error` because StakeError impls the former but not the + // latter... and that conversion is merely surjective........ + // infomercial lady: "if only there were a better way!" + let expected_p = match expected.clone().try_into() { + Ok(v) => v, + Err(_) => panic!("could not unwrap {:?}", expected), + }; + + if got_p != expected_p { + panic!( + "error comparison failed!\n\nGOT: {:#?} / ({:?})\n\nEXPECTED: {:#?} / ({:?})\n\n", + got, got_p, expected, expected_p + ); + } +} diff --git a/program/tests/helpers/stake.rs b/program/tests/helpers/stake.rs new file mode 100644 index 00000000..73047eeb --- /dev/null +++ b/program/tests/helpers/stake.rs @@ -0,0 +1,140 @@ +#![allow(dead_code)] // needed because cargo doesn't understand test usage + +use { + crate::get_account, + bincode::deserialize, + solana_program_test::BanksClient, + solana_sdk::{ + hash::Hash, + native_token::LAMPORTS_PER_SOL, + pubkey::Pubkey, + signature::{Keypair, Signer}, + stake::{ + self, + state::{Meta, Stake, StakeStateV2}, + }, + system_instruction, + transaction::Transaction, + }, + std::convert::TryInto, +}; + +pub const TEST_STAKE_AMOUNT: u64 = 10_000_000_000; // 10 sol + +pub async fn get_stake_account( + banks_client: &mut BanksClient, + pubkey: &Pubkey, +) -> (Meta, Option, u64) { + let stake_account = get_account(banks_client, pubkey).await; + let lamports = stake_account.lamports; + match deserialize::(&stake_account.data).unwrap() { + StakeStateV2::Initialized(meta) => (meta, None, lamports), + StakeStateV2::Stake(meta, stake, _) => (meta, Some(stake), lamports), + _ => unimplemented!(), + } +} + +pub async fn get_stake_account_rent(banks_client: &mut BanksClient) -> u64 { + let rent = banks_client.get_rent().await.unwrap(); + rent.minimum_balance(std::mem::size_of::()) +} + +pub async fn get_pool_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); + let stake_program_minimum = data.try_into().map(u64::from_le_bytes).unwrap(); + + std::cmp::max(stake_program_minimum, LAMPORTS_PER_SOL) +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_independent_stake_account( + banks_client: &mut BanksClient, + fee_payer: &Keypair, + rent_payer: &Keypair, + recent_blockhash: &Hash, + stake: &Keypair, + authorized: &stake::state::Authorized, + lockup: &stake::state::Lockup, + stake_amount: u64, +) -> u64 { + let lamports = get_stake_account_rent(banks_client).await + stake_amount; + let transaction = Transaction::new_signed_with_payer( + &stake::instruction::create_account( + &rent_payer.pubkey(), + &stake.pubkey(), + authorized, + lockup, + lamports, + ), + Some(&fee_payer.pubkey()), + &[fee_payer, rent_payer, stake], + *recent_blockhash, + ); + banks_client.process_transaction(transaction).await.unwrap(); + + lamports +} + +pub async fn create_blank_stake_account( + banks_client: &mut BanksClient, + fee_payer: &Keypair, + rent_payer: &Keypair, + recent_blockhash: &Hash, + stake: &Keypair, +) -> u64 { + let lamports = get_stake_account_rent(banks_client).await; + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &rent_payer.pubkey(), + &stake.pubkey(), + lamports, + std::mem::size_of::() as u64, + &stake::program::id(), + )], + Some(&fee_payer.pubkey()), + &[fee_payer, rent_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(); +} diff --git a/program/tests/helpers/token.rs b/program/tests/helpers/token.rs new file mode 100644 index 00000000..582bd7ab --- /dev/null +++ b/program/tests/helpers/token.rs @@ -0,0 +1,76 @@ +#![allow(dead_code)] + +use { + borsh::BorshDeserialize, + solana_program_test::BanksClient, + solana_sdk::{ + borsh1::try_from_slice_unchecked, + hash::Hash, + program_pack::Pack, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + spl_associated_token_account as atoken, + spl_single_pool::inline_mpl_token_metadata::pda::find_metadata_account, + spl_token::state::{Account, Mint}, +}; + +pub async fn create_ata( + banks_client: &mut BanksClient, + payer: &Keypair, + owner: &Pubkey, + recent_blockhash: &Hash, + pool_mint: &Pubkey, +) { + let instruction = atoken::instruction::create_associated_token_account( + &payer.pubkey(), + owner, + pool_mint, + &spl_token::id(), + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&payer.pubkey()), + &[payer], + *recent_blockhash, + ); + + banks_client.process_transaction(transaction).await.unwrap(); +} + +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 = Account::unpack_from_slice(&token_account.data).unwrap(); + account_info.amount +} + +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 = Mint::unpack_from_slice(&mint_account.data).unwrap(); + account_info.supply +} + +#[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() +} diff --git a/program/tests/initialize.rs b/program/tests/initialize.rs new file mode 100644 index 00000000..88fddd89 --- /dev/null +++ b/program/tests/initialize.rs @@ -0,0 +1,116 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{program_pack::Pack, signature::Signer, stake, transaction::Transaction}, + spl_single_pool::{error::SinglePoolError, id, instruction}, + spl_token::state::Mint, + test_case::test_case, +}; + +#[test_case(true; "minimum_enabled")] +#[test_case(false; "minimum_disabled")] +#[tokio::test] +async fn success(enable_minimum_delegation: bool) { + let mut context = program_test(enable_minimum_delegation) + .start_with_context() + .await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + // mint exists + let mint_account = get_account(&mut context.banks_client, &accounts.mint).await; + Mint::unpack_from_slice(&mint_account.data).unwrap(); + + // stake account exists + let stake_account = get_account(&mut context.banks_client, &accounts.stake_account).await; + assert_eq!(stake_account.owner, stake::program::id()); +} + +#[tokio::test] +async fn fail_double_init() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + let minimum_delegation = accounts.initialize(&mut context).await; + refresh_blockhash(&mut context).await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let instructions = instruction::initialize( + &id(), + &accounts.vote_account.pubkey(), + &context.payer.pubkey(), + &rent, + minimum_delegation, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::PoolAlreadyInitialized); +} + +#[test_case(true; "minimum_enabled")] +#[test_case(false; "minimum_disabled")] +#[tokio::test] +async fn fail_below_pool_minimum(enable_minimum_delegation: bool) { + let mut context = program_test(enable_minimum_delegation) + .start_with_context() + .await; + let accounts = SinglePoolAccounts::default(); + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.validator, + &accounts.voter.pubkey(), + &accounts.withdrawer.pubkey(), + &accounts.vote_account, + ) + .await; + + let rent = context.banks_client.get_rent().await.unwrap(); + let minimum_delegation = get_pool_minimum_delegation( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + ) + .await; + + let instructions = instruction::initialize( + &id(), + &accounts.vote_account.pubkey(), + &context.payer.pubkey(), + &rent, + minimum_delegation - 1, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WrongRentAmount); +} + +// TODO test that init can succeed without mpl program diff --git a/program/tests/reactivate.rs b/program/tests/reactivate.rs new file mode 100644 index 00000000..2b43dfa3 --- /dev/null +++ b/program/tests/reactivate.rs @@ -0,0 +1,156 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{ + account::AccountSharedData, + signature::Signer, + stake::{ + stake_flags::StakeFlags, + state::{Delegation, Stake, StakeStateV2}, + }, + transaction::Transaction, + }, + spl_single_pool::{error::SinglePoolError, id, instruction}, + test_case::test_case, +}; + +#[tokio::test] +async fn success() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_deposit(&mut context, TEST_STAKE_AMOUNT, None) + .await; + advance_epoch(&mut context).await; + + // deactivate the pool stake account + let (meta, stake, _) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let delegation = Delegation { + activation_epoch: 0, + deactivation_epoch: 0, + ..stake.unwrap().delegation + }; + let mut account_data = vec![0; std::mem::size_of::()]; + bincode::serialize_into( + &mut account_data[..], + &StakeStateV2::Stake( + meta, + Stake { + delegation, + ..stake.unwrap() + }, + StakeFlags::empty(), + ), + ) + .unwrap(); + + let mut stake_account = get_account(&mut context.banks_client, &accounts.stake_account).await; + stake_account.data = account_data; + context.set_account( + &accounts.stake_account, + &AccountSharedData::from(stake_account), + ); + + // make sure deposit fails + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WrongStakeStake); + + // reactivate + let instruction = instruction::reactivate_pool_stake(&id(), &accounts.vote_account.pubkey()); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + advance_epoch(&mut context).await; + + // deposit works again + let instructions = instruction::deposit( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + &accounts.alice.pubkey(), + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + assert!(context + .banks_client + .get_account(accounts.alice_stake.pubkey()) + .await + .expect("get_account") + .is_none()); +} + +#[test_case(true; "activated")] +#[test_case(false; "activating")] +#[tokio::test] +async fn fail_not_deactivated(activate: bool) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + if activate { + advance_epoch(&mut context).await; + } + + let instruction = instruction::reactivate_pool_stake(&id(), &accounts.vote_account.pubkey()); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::WrongStakeStake); +} diff --git a/program/tests/update_pool_token_metadata.rs b/program/tests/update_pool_token_metadata.rs new file mode 100644 index 00000000..6642af4b --- /dev/null +++ b/program/tests/update_pool_token_metadata.rs @@ -0,0 +1,127 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{signature::Signer, transaction::Transaction}, + spl_single_pool::{error::SinglePoolError, id, instruction}, + test_case::test_case, +}; + +const UPDATED_NAME: &str = "updated_name"; +const UPDATED_SYMBOL: &str = "USYM"; +const UPDATED_URI: &str = "updated_uri"; + +#[tokio::test] +async fn success_update_pool_token_metadata() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + let instruction = instruction::update_token_metadata( + &id(), + &accounts.vote_account.pubkey(), + &accounts.withdrawer.pubkey(), + UPDATED_NAME.to_string(), + UPDATED_SYMBOL.to_string(), + UPDATED_URI.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.withdrawer], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let metadata = get_metadata_account(&mut context.banks_client, &accounts.mint).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_no_signature() { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + let mut instruction = instruction::update_token_metadata( + &id(), + &accounts.vote_account.pubkey(), + &accounts.withdrawer.pubkey(), + UPDATED_NAME.to_string(), + UPDATED_SYMBOL.to_string(), + UPDATED_URI.to_string(), + ); + assert_eq!(instruction.accounts[3].pubkey, accounts.withdrawer.pubkey()); + instruction.accounts[3].is_signer = false; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::SignatureMissing); +} + +enum BadWithdrawer { + Validator, + Voter, + VoteAccount, +} + +#[test_case(BadWithdrawer::Validator; "validator")] +#[test_case(BadWithdrawer::Voter; "voter")] +#[test_case(BadWithdrawer::VoteAccount; "vote_account")] +#[tokio::test] +async fn fail_bad_withdrawer(withdrawer_type: BadWithdrawer) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts.initialize(&mut context).await; + + let withdrawer = match withdrawer_type { + BadWithdrawer::Validator => &accounts.validator, + BadWithdrawer::Voter => &accounts.voter, + BadWithdrawer::VoteAccount => &accounts.vote_account, + }; + + let instruction = instruction::update_token_metadata( + &id(), + &accounts.vote_account.pubkey(), + &withdrawer.pubkey(), + UPDATED_NAME.to_string(), + UPDATED_SYMBOL.to_string(), + UPDATED_URI.to_string(), + ); + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, withdrawer], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::InvalidMetadataSigner); +} diff --git a/program/tests/withdraw.rs b/program/tests/withdraw.rs new file mode 100644 index 00000000..4619613b --- /dev/null +++ b/program/tests/withdraw.rs @@ -0,0 +1,257 @@ +#![allow(clippy::arithmetic_side_effects)] +#![cfg(feature = "test-sbf")] + +mod helpers; + +use { + helpers::*, + solana_program_test::*, + solana_sdk::{signature::Signer, transaction::Transaction}, + spl_single_pool::{error::SinglePoolError, id, instruction}, + test_case::test_case, +}; + +#[test_case(true, 0, false, false, false; "activated::minimum_disabled")] +#[test_case(true, 0, false, false, true; "activated::minimum_disabled::small")] +#[test_case(true, 0, false, true, false; "activated::minimum_enabled")] +#[test_case(false, 0, false, false, false; "activating::minimum_disabled")] +#[test_case(false, 0, false, false, true; "activating::minimum_disabled::small")] +#[test_case(false, 0, false, true, false; "activating::minimum_enabled")] +#[test_case(true, 100_000, false, false, false; "activated::extra")] +#[test_case(false, 100_000, false, false, false; "activating::extra")] +#[test_case(true, 0, true, false, false; "activated::second")] +#[test_case(false, 0, true, false, false; "activating::second")] +#[tokio::test] +async fn success( + activate: bool, + extra_lamports: u64, + prior_deposit: bool, + enable_minimum_delegation: bool, + small_deposit: bool, +) { + let mut context = program_test(enable_minimum_delegation) + .start_with_context() + .await; + let accounts = SinglePoolAccounts::default(); + + let amount_deposited = if small_deposit { 1 } else { TEST_STAKE_AMOUNT }; + + let minimum_delegation = accounts + .initialize_for_withdraw( + &mut context, + amount_deposited, + if prior_deposit { + Some(TEST_STAKE_AMOUNT * 10) + } else { + None + }, + activate, + ) + .await; + + let (_, _, pool_lamports_before) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + + let wallet_lamports_before = get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + if extra_lamports > 0 { + transfer( + &mut context.banks_client, + &context.payer, + &context.last_blockhash, + &accounts.stake_account, + extra_lamports, + ) + .await; + } + + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[&context.payer, &accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let wallet_lamports_after = get_account(&mut context.banks_client, &accounts.alice.pubkey()) + .await + .lamports; + + let (_, alice_stake_after, _) = + get_stake_account(&mut context.banks_client, &accounts.alice_stake.pubkey()).await; + let alice_stake_after = alice_stake_after.unwrap().delegation.stake; + + let (_, pool_stake_after, pool_lamports_after) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let pool_stake_after = pool_stake_after.unwrap().delegation.stake; + + // when active, the depositor gets their rent back, but when activating, its + // just added to stake + let expected_deposit = if activate { + amount_deposited + } else { + amount_deposited + get_stake_account_rent(&mut context.banks_client).await + }; + + let prior_deposits = if prior_deposit { + if activate { + TEST_STAKE_AMOUNT * 10 + } else { + TEST_STAKE_AMOUNT * 10 + get_stake_account_rent(&mut context.banks_client).await + } + } else { + 0 + }; + + // alice received her stake back + assert_eq!(alice_stake_after, expected_deposit); + + // alice nothing to withdraw + // (we create the blank account before getting wallet_lamports_before) + assert_eq!(wallet_lamports_after, wallet_lamports_before); + + // pool retains minstake + assert_eq!(pool_stake_after, prior_deposits + minimum_delegation); + + // pool lamports otherwise unchanged. unexpected transfers affect nothing + assert_eq!( + pool_lamports_after, + pool_lamports_before - expected_deposit + extra_lamports + ); + + // alice has no tokens + assert_eq!( + get_token_balance(&mut context.banks_client, &accounts.alice_token).await, + 0, + ); + + // tokens were burned + assert_eq!( + get_token_supply(&mut context.banks_client, &accounts.mint).await, + prior_deposits, + ); +} + +#[tokio::test] +async fn success_with_rewards() { + let alice_deposit = TEST_STAKE_AMOUNT; + let bob_deposit = TEST_STAKE_AMOUNT * 3; + + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + let minimum_delegation = accounts + .initialize_for_withdraw(&mut context, alice_deposit, Some(bob_deposit), true) + .await; + + context.increment_vote_account_credits(&accounts.vote_account.pubkey(), 1); + advance_epoch(&mut context).await; + + let alice_tokens = get_token_balance(&mut context.banks_client, &accounts.alice_token).await; + let bob_tokens = get_token_balance(&mut context.banks_client, &accounts.bob_token).await; + + // tokens correspond to deposit after rewards + assert_eq!(alice_tokens, alice_deposit); + assert_eq!(bob_tokens, bob_deposit); + + let (_, pool_stake, _) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let pool_stake = pool_stake.unwrap().delegation.stake; + let total_rewards = pool_stake - alice_deposit - bob_deposit - minimum_delegation; + + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.alice_stake.pubkey(), + &accounts.alice.pubkey(), + &accounts.alice_token, + &accounts.alice.pubkey(), + alice_tokens, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let alice_tokens = get_token_balance(&mut context.banks_client, &accounts.alice_token).await; + let bob_tokens = get_token_balance(&mut context.banks_client, &accounts.bob_token).await; + + let (_, alice_stake, _) = + get_stake_account(&mut context.banks_client, &accounts.alice_stake.pubkey()).await; + let alice_rewards = alice_stake.unwrap().delegation.stake - alice_deposit; + + let (_, bob_stake, _) = + get_stake_account(&mut context.banks_client, &accounts.stake_account).await; + let bob_rewards = bob_stake.unwrap().delegation.stake - minimum_delegation - bob_deposit; + + // alice tokens are fully burned, bob remains unchanged + assert_eq!(alice_tokens, 0); + assert_eq!(bob_tokens, bob_deposit); + + // reward amounts are proportional to deposits + assert_eq!( + (alice_rewards as f64 / total_rewards as f64 * 100.0).round(), + 25.0 + ); + assert_eq!( + (bob_rewards as f64 / total_rewards as f64 * 100.0).round(), + 75.0 + ); +} + +#[test_case(true; "activated")] +#[test_case(false; "activating")] +#[tokio::test] +async fn fail_automorphic(activate: bool) { + let mut context = program_test(false).start_with_context().await; + let accounts = SinglePoolAccounts::default(); + accounts + .initialize_for_withdraw(&mut context, TEST_STAKE_AMOUNT, None, activate) + .await; + + let instructions = instruction::withdraw( + &id(), + &accounts.pool, + &accounts.stake_account, + &accounts.stake_authority, + &accounts.alice_token, + &accounts.alice.pubkey(), + TEST_STAKE_AMOUNT, + ); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&accounts.alice.pubkey()), + &[&accounts.alice], + context.last_blockhash, + ); + + let e = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err(); + check_error(e, SinglePoolError::InvalidPoolStakeAccountUsage); +}