From 792c5d6fffde4108f9b30a2a413c08d8c286d7f2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:39:44 -0700 Subject: [PATCH] v2.0: stake-program: `MoveStake` and `MoveLamports` (backport of #1415) (#1928) stake-program: `MoveStake` and `MoveLamports` (#1415) implement two new instructions for moving delegated stake and undelegated lamports, respectively, between accounts with the same Authorized and Lockup using the Staker authority (cherry picked from commit 361ade4439c0d791514590e7431d0a6a7f5045ee) Co-authored-by: hana <81144685+2501babe@users.noreply.github.com> --- Cargo.lock | 13 + Cargo.toml | 1 + programs/stake-tests/Cargo.toml | 26 + .../tests/test_move_stake_and_lamports.rs | 1286 +++++++++++++++++ programs/stake/src/stake_instruction.rs | 82 +- programs/stake/src/stake_state.rs | 265 ++++ sdk/program/src/stake/instruction.rs | 70 + sdk/src/feature_set.rs | 6 + transaction-status/src/parse_stake.rs | 73 + 9 files changed, 1821 insertions(+), 1 deletion(-) create mode 100644 programs/stake-tests/Cargo.toml create mode 100644 programs/stake-tests/tests/test_move_stake_and_lamports.rs diff --git a/Cargo.lock b/Cargo.lock index 592dc7a5bf6176..99f0a393583dfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7369,6 +7369,19 @@ dependencies = [ "test-case", ] +[[package]] +name = "solana-stake-program-tests" +version = "2.0.2" +dependencies = [ + "assert_matches", + "bincode", + "rustc_version 0.4.0", + "solana-program-test", + "solana-sdk", + "solana-vote-program", + "test-case", +] + [[package]] name = "solana-storage-bigtable" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index 2f9f3cd818fab9..2cb718597e8ede 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ members = [ "programs/ed25519-tests", "programs/loader-v4", "programs/stake", + "programs/stake-tests", "programs/system", "programs/vote", "programs/zk-elgamal-proof", diff --git a/programs/stake-tests/Cargo.toml b/programs/stake-tests/Cargo.toml new file mode 100644 index 00000000000000..4c89f4dd6d0fbf --- /dev/null +++ b/programs/stake-tests/Cargo.toml @@ -0,0 +1,26 @@ +# This package only exists to avoid circular dependencies during cargo publish: +# solana-program-test <--> solana-stake-program + +[package] +name = "solana-stake-program-tests" +publish = false +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +bincode = { workspace = true } +solana-program-test = { workspace = true } +solana-sdk = { workspace = true } +solana-vote-program = { workspace = true } +test-case = { workspace = true } + +[build-dependencies] +rustc_version = { workspace = true } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/programs/stake-tests/tests/test_move_stake_and_lamports.rs b/programs/stake-tests/tests/test_move_stake_and_lamports.rs new file mode 100644 index 00000000000000..7c67db2d5520b9 --- /dev/null +++ b/programs/stake-tests/tests/test_move_stake_and_lamports.rs @@ -0,0 +1,1286 @@ +#![allow(clippy::arithmetic_side_effects)] + +// NOTE this is temporarily ported from the bpf stake program repo so MoveStake and MoveLamports can be tested comprehensively +// in the future we will either port *all* instruction tests from bpf stake program and remove existing stakeinstruction tests +// or we will develop a text fixture system that allows fuzzing and obsoletes both existing test suites +// in other words the utility functions in this file should not be broken out into modules or used elsewhere + +use { + solana_program_test::*, + solana_sdk::{ + account::Account as SolanaAccount, + entrypoint::ProgramResult, + feature_set::move_stake_and_move_lamports_ixs, + instruction::Instruction, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + signers::Signers, + stake::{ + self, + instruction::{self as ixn, StakeError}, + program as stake_program, + state::{Authorized, Lockup, Meta, Stake, StakeStateV2}, + }, + system_instruction, system_program, + sysvar::{clock::Clock, stake_history::StakeHistory}, + transaction::{Transaction, TransactionError}, + }, + solana_vote_program::{ + self, vote_instruction, + vote_state::{VoteInit, VoteState, VoteStateVersions}, + }, + test_case::test_matrix, +}; + +const NO_SIGNERS: &[Keypair] = &[]; + +fn program_test() -> ProgramTest { + program_test_without_features(&[]) +} + +fn program_test_without_features(feature_ids: &[Pubkey]) -> ProgramTest { + let mut program_test = ProgramTest::default(); + for feature_id in feature_ids { + program_test.deactivate_feature(*feature_id); + } + + program_test +} + +#[derive(Debug, PartialEq)] +struct Accounts { + validator: Keypair, + voter: Keypair, + withdrawer: Keypair, + vote_account: Keypair, +} + +impl Accounts { + async fn initialize(&self, context: &mut ProgramTestContext) { + let slot = context.genesis_config().epoch_schedule.first_normal_slot + 1; + context.warp_to_slot(slot).unwrap(); + + create_vote( + context, + &self.validator, + &self.voter.pubkey(), + &self.withdrawer.pubkey(), + &self.vote_account, + ) + .await; + } +} + +impl Default for Accounts { + fn default() -> Self { + Self { + validator: Keypair::new(), + voter: Keypair::new(), + withdrawer: Keypair::new(), + vote_account: Keypair::new(), + } + } +} + +async fn create_vote( + context: &mut ProgramTestContext, + validator: &Keypair, + voter: &Pubkey, + withdrawer: &Pubkey, + vote_account: &Keypair, +) { + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_voter = rent.minimum_balance(VoteState::size_of()); + + let mut instructions = vec![system_instruction::create_account( + &context.payer.pubkey(), + &validator.pubkey(), + rent.minimum_balance(0), + 0, + &system_program::id(), + )]; + instructions.append(&mut vote_instruction::create_account_with_config( + &context.payer.pubkey(), + &vote_account.pubkey(), + &VoteInit { + node_pubkey: validator.pubkey(), + authorized_voter: *voter, + authorized_withdrawer: *withdrawer, + ..VoteInit::default() + }, + rent_voter, + vote_instruction::CreateVoteAccountConfig { + space: VoteStateVersions::vote_state_size_of(true) as u64, + ..Default::default() + }, + )); + + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&context.payer.pubkey()), + &[validator, vote_account, &context.payer], + context.last_blockhash, + ); + + // ignore errors for idempotency + let _ = context.banks_client.process_transaction(transaction).await; +} + +async fn transfer(context: &mut ProgramTestContext, recipient: &Pubkey, amount: u64) { + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::transfer( + &context.payer.pubkey(), + recipient, + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +async fn advance_epoch(context: &mut ProgramTestContext) { + refresh_blockhash(context).await; + + 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(); +} + +async fn refresh_blockhash(context: &mut ProgramTestContext) { + context.last_blockhash = context + .banks_client + .get_new_latest_blockhash(&context.last_blockhash) + .await + .unwrap(); +} + +async fn get_account(banks_client: &mut BanksClient, pubkey: &Pubkey) -> SolanaAccount { + banks_client + .get_account(*pubkey) + .await + .expect("client error") + .expect("account not found") +} + +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 bincode::deserialize::(&stake_account.data).unwrap() { + StakeStateV2::Initialized(meta) => (meta, None, lamports), + StakeStateV2::Stake(meta, stake, _) => (meta, Some(stake), lamports), + StakeStateV2::Uninitialized => panic!("panic: uninitialized"), + _ => unimplemented!(), + } +} + +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::()) +} + +async fn get_effective_stake(banks_client: &mut BanksClient, pubkey: &Pubkey) -> u64 { + let clock = banks_client.get_sysvar::().await.unwrap(); + let stake_history = banks_client.get_sysvar::().await.unwrap(); + let stake_account = get_account(banks_client, pubkey).await; + match bincode::deserialize::(&stake_account.data).unwrap() { + StakeStateV2::Stake(_, stake, _) => { + stake + .delegation + .stake_activating_and_deactivating(clock.epoch, &stake_history, Some(0)) + .effective + } + _ => 0, + } +} + +async fn get_minimum_delegation(context: &mut ProgramTestContext) -> u64 { + let transaction = Transaction::new_signed_with_payer( + &[stake::instruction::get_minimum_delegation()], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let mut data = context + .banks_client + .simulate_transaction(transaction) + .await + .unwrap() + .simulation_details + .unwrap() + .return_data + .unwrap() + .data; + data.resize(8, 0); + + data.try_into().map(u64::from_le_bytes).unwrap() +} + +async fn create_blank_stake_account_from_keypair( + context: &mut ProgramTestContext, + stake: &Keypair, +) -> Pubkey { + let lamports = get_stake_account_rent(&mut context.banks_client).await; + + let transaction = Transaction::new_signed_with_payer( + &[system_instruction::create_account( + &context.payer.pubkey(), + &stake.pubkey(), + lamports, + StakeStateV2::size_of() as u64, + &stake_program::id(), + )], + Some(&context.payer.pubkey()), + &[&context.payer, stake], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + stake.pubkey() +} + +async fn process_instruction( + context: &mut ProgramTestContext, + instruction: &Instruction, + additional_signers: &T, +) -> ProgramResult { + let mut transaction = + Transaction::new_with_payer(&[instruction.clone()], Some(&context.payer.pubkey())); + + transaction.partial_sign(&[&context.payer], context.last_blockhash); + transaction.sign(additional_signers, context.last_blockhash); + + match context.banks_client.process_transaction(transaction).await { + Ok(_) => Ok(()), + Err(e) => { + // banks client error -> transaction error -> instruction error -> program error + match e.unwrap() { + TransactionError::InstructionError(_, e) => Err(e.try_into().unwrap()), + TransactionError::InsufficientFundsForRent { .. } => { + Err(ProgramError::InsufficientFunds) + } + _ => panic!("couldnt convert {:?} to ProgramError", e), + } + } + } +} + +async fn test_instruction_with_missing_signers( + context: &mut ProgramTestContext, + instruction: &Instruction, + additional_signers: &Vec<&Keypair>, +) { + // remove every signer one by one and ensure we always fail + for i in 0..instruction.accounts.len() { + if instruction.accounts[i].is_signer { + let mut instruction = instruction.clone(); + instruction.accounts[i].is_signer = false; + let reduced_signers: Vec<_> = additional_signers + .iter() + .filter(|s| s.pubkey() != instruction.accounts[i].pubkey) + .collect(); + + let e = process_instruction(context, &instruction, &reduced_signers) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + } + + // now make sure the instruction succeeds + process_instruction(context, instruction, additional_signers) + .await + .unwrap(); +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum StakeLifecycle { + Uninitialized = 0, + Initialized, + Activating, + Active, + Deactivating, + Deactive, +} +impl StakeLifecycle { + // (stake, staker, withdrawer) + async fn new_stake_account( + self, + context: &mut ProgramTestContext, + vote_account: &Pubkey, + staked_amount: u64, + ) -> (Keypair, Keypair, Keypair) { + let stake_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + self.new_stake_account_fully_specified( + context, + vote_account, + staked_amount, + &stake_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + + (stake_keypair, staker_keypair, withdrawer_keypair) + } + + #[allow(clippy::too_many_arguments)] + async fn new_stake_account_fully_specified( + self, + context: &mut ProgramTestContext, + vote_account: &Pubkey, + staked_amount: u64, + stake_keypair: &Keypair, + staker_keypair: &Keypair, + withdrawer_keypair: &Keypair, + lockup: &Lockup, + ) { + let authorized = Authorized { + staker: staker_keypair.pubkey(), + withdrawer: withdrawer_keypair.pubkey(), + }; + + let stake = create_blank_stake_account_from_keypair(context, stake_keypair).await; + if staked_amount > 0 { + transfer(context, &stake, staked_amount).await; + } + + if self >= StakeLifecycle::Initialized { + let instruction = ixn::initialize(&stake, &authorized, lockup); + process_instruction(context, &instruction, NO_SIGNERS) + .await + .unwrap(); + } + + if self >= StakeLifecycle::Activating { + let instruction = ixn::delegate_stake(&stake, &staker_keypair.pubkey(), vote_account); + process_instruction(context, &instruction, &vec![staker_keypair]) + .await + .unwrap(); + } + + if self >= StakeLifecycle::Active { + advance_epoch(context).await; + assert_eq!( + get_effective_stake(&mut context.banks_client, &stake).await, + staked_amount, + ); + } + + if self >= StakeLifecycle::Deactivating { + let instruction = ixn::deactivate_stake(&stake, &staker_keypair.pubkey()); + process_instruction(context, &instruction, &vec![staker_keypair]) + .await + .unwrap(); + } + + if self == StakeLifecycle::Deactive { + advance_epoch(context).await; + assert_eq!( + get_effective_stake(&mut context.banks_client, &stake).await, + 0, + ); + } + } +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [false, true], + [false, true] +)] +#[tokio::test] +async fn test_move_stake( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + full_move: bool, + has_lockup: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let rent_exempt_reserve = get_stake_account_rent(&mut context.banks_client).await; + let minimum_delegation = get_minimum_delegation(&mut context).await; + + // source has 2x minimum so we can easily test an unfunded destination + let source_staked_amount = minimum_delegation * 2; + + // this is the amount of *staked* lamports for test checks + // destinations may have excess lamports but these are *never* activated by move + let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + // test with and without lockup. both of these cases pass, we test failures elsewhere + let lockup = if has_lockup { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + let lockup = Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 100, + custodian: Pubkey::new_unique(), + }; + + assert!(lockup.is_in_force(&clock, None)); + lockup + } else { + Lockup::default() + }; + + // we put an extra minimum in every account, unstaked, to test that no new lamports activate + // name them here so our asserts are readable + let source_excess = minimum_delegation; + let dest_excess = minimum_delegation; + + let move_source_keypair = Keypair::new(); + let move_dest_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + // create source stake + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + &move_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_source = move_source_keypair.pubkey(); + let mut source_account = get_account(&mut context.banks_client, &move_source).await; + let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap(); + + // create dest stake with same authorities + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // true up source epoch if transient + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state { + match move_source_type { + StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, + StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, + _ => (), + } + } + + source_account.data = bincode::serialize(&source_stake_state).unwrap(); + context.set_account(&move_source, &source_account.into()); + } + + // our inactive accounts have extra lamports, lets not let active feel left out + if move_dest_type == StakeLifecycle::Active { + transfer(&mut context, &move_dest, dest_excess).await; + } + + // hey why not spread the love around to everyone + transfer(&mut context, &move_source, source_excess).await; + + // alright first things first, clear out all the state failures + match (move_source_type, move_dest_type) { + // valid + (StakeLifecycle::Active, StakeLifecycle::Initialized) + | (StakeLifecycle::Active, StakeLifecycle::Active) + | (StakeLifecycle::Active, StakeLifecycle::Deactive) => (), + // invalid! get outta my test + _ => { + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + if full_move { + source_staked_amount + } else { + minimum_delegation + }, + ); + + // this is InvalidAccountData sometimes and Custom(5) sometimes but i dont care + process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + return; + } + } + + // source has 2x minimum (always 2 sol because these tests dont have featuresets) + // so first for inactive accounts lets undershoot and fail for underfunded dest + if move_dest_type != StakeLifecycle::Active { + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation - 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + } + + // now lets overshoot and fail for underfunded source + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation + 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + // now we do it juuust right + let instruction = ixn::move_stake( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + if full_move { + source_staked_amount + } else { + minimum_delegation + }, + ); + + test_instruction_with_missing_signers(&mut context, &instruction, &vec![&staker_keypair]).await; + + if full_move { + let (_, option_source_stake, source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + + // source is deactivated and rent/excess stay behind + assert!(option_source_stake.is_none()); + assert_eq!(source_lamports, source_excess + rent_exempt_reserve); + + let (_, Some(dest_stake), dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await + else { + panic!("dest should be active") + }; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest captured the entire source delegation, kept its rent/excess, didnt activate its excess + assert_eq!( + dest_stake.delegation.stake, + source_staked_amount + dest_staked_amount + ); + assert_eq!(dest_effective_stake, dest_stake.delegation.stake); + assert_eq!( + dest_lamports, + dest_effective_stake + dest_excess + rent_exempt_reserve + ); + } else { + let (_, Some(source_stake), source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await + else { + panic!("source should be active") + }; + let source_effective_stake = + get_effective_stake(&mut context.banks_client, &move_source).await; + + // half of source delegation moved over, excess stayed behind + assert_eq!(source_stake.delegation.stake, source_staked_amount / 2); + assert_eq!(source_effective_stake, source_stake.delegation.stake); + assert_eq!( + source_lamports, + source_effective_stake + source_excess + rent_exempt_reserve + ); + + let (_, Some(dest_stake), dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await + else { + panic!("dest should be active") + }; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest mirrors our observations + assert_eq!( + dest_stake.delegation.stake, + source_staked_amount / 2 + dest_staked_amount + ); + assert_eq!(dest_effective_stake, dest_stake.delegation.stake); + assert_eq!( + dest_lamports, + dest_effective_stake + dest_excess + rent_exempt_reserve + ); + } +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, + StakeLifecycle::Deactivating, StakeLifecycle::Deactive], + [false, true], + [false, true] +)] +#[tokio::test] +async fn test_move_lamports( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + different_votes: bool, + has_lockup: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let rent_exempt_reserve = get_stake_account_rent(&mut context.banks_client).await; + let minimum_delegation = get_minimum_delegation(&mut context).await; + + // put minimum in both accounts if theyre active + let source_staked_amount = if move_source_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + let dest_staked_amount = if move_dest_type == StakeLifecycle::Active { + minimum_delegation + } else { + 0 + }; + + // test with and without lockup. both of these cases pass, we test failures elsewhere + let lockup = if has_lockup { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + let lockup = Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 100, + custodian: Pubkey::new_unique(), + }; + + assert!(lockup.is_in_force(&clock, None)); + lockup + } else { + Lockup::default() + }; + + // we put an extra minimum in every account, unstaked, to test moving them + let source_excess = minimum_delegation; + let dest_excess = minimum_delegation; + + let move_source_keypair = Keypair::new(); + let move_dest_keypair = Keypair::new(); + let staker_keypair = Keypair::new(); + let withdrawer_keypair = Keypair::new(); + + // make a separate vote account if needed + let dest_vote_account = if different_votes { + let vote_account = Keypair::new(); + create_vote( + &mut context, + &Keypair::new(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &vote_account, + ) + .await; + + vote_account.pubkey() + } else { + accounts.vote_account.pubkey() + }; + + // create source stake + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_source = move_source_keypair.pubkey(); + let mut source_account = get_account(&mut context.banks_client, &move_source).await; + let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap(); + + // create dest stake with same authorities + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &dest_vote_account, + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // true up source epoch if transient + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state { + match move_source_type { + StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch, + StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch, + _ => (), + } + } + + source_account.data = bincode::serialize(&source_stake_state).unwrap(); + context.set_account(&move_source, &source_account.into()); + } + + // if we activated the initial amount we need to top up with the test lamports + if move_source_type == StakeLifecycle::Active { + transfer(&mut context, &move_source, source_excess).await; + } + if move_dest_type == StakeLifecycle::Active { + transfer(&mut context, &move_dest, dest_excess).await; + } + + // clear out state failures + if move_source_type == StakeLifecycle::Activating + || move_source_type == StakeLifecycle::Deactivating + || move_dest_type == StakeLifecycle::Deactivating + { + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess, + ); + + process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + return; + } + + // overshoot and fail for underfunded source + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess + 1, + ); + + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + let (_, _, before_source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + let (_, _, before_dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await; + + // now properly move the full excess + let instruction = ixn::move_lamports( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + source_excess, + ); + + test_instruction_with_missing_signers(&mut context, &instruction, &vec![&staker_keypair]).await; + + let (_, _, after_source_lamports) = + get_stake_account(&mut context.banks_client, &move_source).await; + let source_effective_stake = get_effective_stake(&mut context.banks_client, &move_source).await; + + // source activation didnt change + assert_eq!(source_effective_stake, source_staked_amount); + + // source lamports are right + assert_eq!( + after_source_lamports, + before_source_lamports - minimum_delegation + ); + assert_eq!( + after_source_lamports, + source_effective_stake + rent_exempt_reserve + ); + + let (_, _, after_dest_lamports) = + get_stake_account(&mut context.banks_client, &move_dest).await; + let dest_effective_stake = get_effective_stake(&mut context.banks_client, &move_dest).await; + + // dest activation didnt change + assert_eq!(dest_effective_stake, dest_staked_amount); + + // dest lamports are right + assert_eq!( + after_dest_lamports, + before_dest_lamports + minimum_delegation + ); + assert_eq!( + after_dest_lamports, + dest_effective_stake + rent_exempt_reserve + source_excess + dest_excess + ); +} + +#[test_matrix( + [(StakeLifecycle::Active, StakeLifecycle::Uninitialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Initialized), + (StakeLifecycle::Uninitialized, StakeLifecycle::Uninitialized)], + [false, true] +)] +#[tokio::test] +async fn test_move_uninitialized_fail( + move_types: (StakeLifecycle, StakeLifecycle), + move_lamports: bool, +) { + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let (move_source_type, move_dest_type) = move_types; + + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + 0, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let source_signer = if move_source_type == StakeLifecycle::Uninitialized { + &move_source_keypair + } else { + &staker_keypair + }; + + let instruction = if move_lamports { + ixn::move_lamports( + &move_source, + &move_dest, + &source_signer.pubkey(), + minimum_delegation, + ) + } else { + ixn::move_stake( + &move_source, + &move_dest, + &source_signer.pubkey(), + minimum_delegation, + ) + }; + + let e = process_instruction(&mut context, &instruction, &vec![source_signer]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidAccountData); +} + +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Active, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactive], + [false, true] +)] +#[tokio::test] +async fn test_move_general_fail( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + move_lamports: bool, +) { + // the test_matrix includes all valid source/dest combinations for MoveLamports + // we dont test invalid combinations because they would fail regardless of the fail cases we test here + // valid source/dest for MoveStake are a strict subset of MoveLamports + // source must be active, and dest must be active or inactive. so we skip the additional invalid MoveStake cases + if !move_lamports + && (move_source_type != StakeLifecycle::Active + || move_dest_type == StakeLifecycle::Activating) + { + return; + } + + let mut context = program_test().start_with_context().await; + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let in_force_lockup = { + let clock = context.banks_client.get_sysvar::().await.unwrap(); + Lockup { + unix_timestamp: 0, + epoch: clock.epoch + 1_000_000, + custodian: Pubkey::new_unique(), + } + }; + + let mk_ixn = if move_lamports { + ixn::move_lamports + } else { + ixn::move_stake + }; + + // we can reuse source but will need a lot of dest + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + transfer(&mut context, &move_source, minimum_delegation).await; + + // self-move fails + // NOTE this error type is an artifact of the native program interface + // when we move to bpf, it should actually hit the processor error + let instruction = mk_ixn( + &move_source, + &move_source, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::AccountBorrowFailed); + + // first we make a "normal" move dest + { + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + // zero move fails + let instruction = mk_ixn(&move_source, &move_dest, &staker_keypair.pubkey(), 0); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidArgument); + + // sign with withdrawer fails + let instruction = mk_ixn( + &move_source, + &move_dest, + &withdrawer_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&withdrawer_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + + // good place to test source lockup + let move_locked_source_keypair = Keypair::new(); + move_source_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + &move_locked_source_keypair, + &staker_keypair, + &withdrawer_keypair, + &in_force_lockup, + ) + .await; + let move_locked_source = move_locked_source_keypair.pubkey(); + transfer(&mut context, &move_locked_source, minimum_delegation).await; + + let instruction = mk_ixn( + &move_locked_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + } + + // staker mismatch + { + let move_dest_keypair = Keypair::new(); + let throwaway = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &throwaway, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &throwaway.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&throwaway]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + + // withdrawer mismatch + { + let move_dest_keypair = Keypair::new(); + let throwaway = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &throwaway, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &throwaway.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&throwaway]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::MissingRequiredSignature); + } + + // dest lockup + { + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &in_force_lockup, + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::MergeMismatch.into()); + } + + // lastly we test different vote accounts for move_stake + if !move_lamports && move_dest_type == StakeLifecycle::Active { + let dest_vote_account_keypair = Keypair::new(); + create_vote( + &mut context, + &Keypair::new(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &dest_vote_account_keypair, + ) + .await; + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &dest_vote_account_keypair.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, StakeError::VoteAddressMismatch.into()); + } +} + +// this test is only to be sure the feature gate is safe +// once the feature has been activated, this can all be deleted +#[test_matrix( + [StakeLifecycle::Initialized, StakeLifecycle::Active, StakeLifecycle::Deactive], + [StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active, StakeLifecycle::Deactive], + [false, true] +)] +#[tokio::test] +async fn test_move_feature_gate_fail( + move_source_type: StakeLifecycle, + move_dest_type: StakeLifecycle, + move_lamports: bool, +) { + // the test_matrix includes all valid source/dest combinations for MoveLamports + // we dont test invalid combinations because they would fail regardless of the fail cases we test here + // valid source/dest for MoveStake are a strict subset of MoveLamports + // source must be active, and dest must be active or inactive. so we skip the additional invalid MoveStake cases + if !move_lamports + && (move_source_type != StakeLifecycle::Active + || move_dest_type == StakeLifecycle::Activating) + { + return; + } + + let mut context = program_test_without_features(&[move_stake_and_move_lamports_ixs::id()]) + .start_with_context() + .await; + + let accounts = Accounts::default(); + accounts.initialize(&mut context).await; + + let minimum_delegation = get_minimum_delegation(&mut context).await; + let source_staked_amount = minimum_delegation * 2; + + let mk_ixn = if move_lamports { + ixn::move_lamports + } else { + ixn::move_stake + }; + + let (move_source_keypair, staker_keypair, withdrawer_keypair) = move_source_type + .new_stake_account( + &mut context, + &accounts.vote_account.pubkey(), + source_staked_amount, + ) + .await; + let move_source = move_source_keypair.pubkey(); + transfer(&mut context, &move_source, minimum_delegation).await; + + let move_dest_keypair = Keypair::new(); + move_dest_type + .new_stake_account_fully_specified( + &mut context, + &accounts.vote_account.pubkey(), + minimum_delegation, + &move_dest_keypair, + &staker_keypair, + &withdrawer_keypair, + &Lockup::default(), + ) + .await; + let move_dest = move_dest_keypair.pubkey(); + + let instruction = mk_ixn( + &move_source, + &move_dest, + &staker_keypair.pubkey(), + minimum_delegation, + ); + let e = process_instruction(&mut context, &instruction, &vec![&staker_keypair]) + .await + .unwrap_err(); + assert_eq!(e, ProgramError::InvalidInstructionData); +} diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs index 203dcebe68c462..1b874cd9750596 100644 --- a/programs/stake/src/stake_instruction.rs +++ b/programs/stake/src/stake_instruction.rs @@ -1,7 +1,8 @@ use { crate::stake_state::{ authorize, authorize_with_seed, deactivate, deactivate_delinquent, delegate, initialize, - merge, new_warmup_cooldown_rate_epoch, redelegate, set_lockup, split, withdraw, + merge, move_lamports, move_stake, new_warmup_cooldown_rate_epoch, redelegate, set_lockup, + split, withdraw, }, log::*, solana_program_runtime::{ @@ -352,6 +353,44 @@ declare_process_instruction!(Entrypoint, DEFAULT_COMPUTE_UNITS, |invoke_context| Err(InstructionError::InvalidInstructionData) } } + StakeInstruction::MoveStake(lamports) => { + if invoke_context + .get_feature_set() + .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) + { + instruction_context.check_number_of_instruction_accounts(3)?; + move_stake( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + 2, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } + } + StakeInstruction::MoveLamports(lamports) => { + if invoke_context + .get_feature_set() + .is_active(&feature_set::move_stake_and_move_lamports_ixs::id()) + { + instruction_context.check_number_of_instruction_accounts(3)?; + move_lamports( + invoke_context, + transaction_context, + instruction_context, + 0, + lamports, + 1, + 2, + ) + } else { + Err(InstructionError::InvalidInstructionData) + } + } } }); @@ -476,6 +515,7 @@ mod tests { .collect(); pubkeys.insert(clock::id()); pubkeys.insert(epoch_schedule::id()); + pubkeys.insert(stake_history::id()); #[allow(deprecated)] pubkeys .iter() @@ -671,6 +711,26 @@ mod tests { ), Err(InstructionError::InvalidAccountData), ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_stake( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountData), + ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_lamports( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(InstructionError::InvalidAccountData), + ); } #[test_case(feature_set_no_minimum_delegation(); "no_min_delegation")] @@ -8030,6 +8090,26 @@ mod tests { )[2], Err(StakeError::EpochRewardsActive.into()), ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_stake( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(StakeError::EpochRewardsActive.into()), + ); + process_instruction_as_one_arg( + Arc::clone(&feature_set), + &instruction::move_lamports( + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + 100, + ), + Err(StakeError::EpochRewardsActive.into()), + ); // Only GetMinimumDelegation should not return StakeError::EpochRewardsActive process_instruction_as_one_arg( diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs index d3ee57beca43f2..baee081034703d 100644 --- a/programs/stake/src/stake_state.rs +++ b/programs/stake/src/stake_state.rs @@ -132,6 +132,86 @@ fn redelegate_stake( Ok(()) } +fn move_stake_or_lamports_shared_checks( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account: &BorrowedAccount, + lamports: u64, + destination_account: &BorrowedAccount, + stake_authority_index: IndexOfAccount, +) -> Result<(MergeKind, MergeKind), InstructionError> { + // authority must sign + let stake_authority_pubkey = transaction_context.get_key_of_account_at_index( + instruction_context + .get_index_of_instruction_account_in_transaction(stake_authority_index)?, + )?; + if !instruction_context.is_instruction_account_signer(stake_authority_index)? { + return Err(InstructionError::MissingRequiredSignature); + } + + let mut signers = HashSet::new(); + signers.insert(*stake_authority_pubkey); + + // check owners + if *source_account.get_owner() != id() || *destination_account.get_owner() != id() { + return Err(InstructionError::IncorrectProgramId); + } + + // confirm not the same account + if *source_account.get_key() == *destination_account.get_key() { + return Err(InstructionError::InvalidInstructionData); + } + + // source and destination must be writable + if !source_account.is_writable() || !destination_account.is_writable() { + return Err(InstructionError::InvalidInstructionData); + } + + // must move something + if lamports == 0 { + return Err(InstructionError::InvalidArgument); + } + + let clock = invoke_context.get_sysvar_cache().get_clock()?; + let stake_history = invoke_context.get_sysvar_cache().get_stake_history()?; + + // get_if_mergeable ensures accounts are not partly activated or in any form of deactivating + // we still need to exclude activating state ourselves + let source_merge_kind = MergeKind::get_if_mergeable( + invoke_context, + &source_account.get_state()?, + source_account.get_lamports(), + &clock, + &stake_history, + )?; + + // Authorized staker is allowed to move stake + source_merge_kind + .meta() + .authorized + .check(&signers, StakeAuthorize::Staker)?; + + // same transient assurance as with source + let destination_merge_kind = MergeKind::get_if_mergeable( + invoke_context, + &destination_account.get_state()?, + destination_account.get_lamports(), + &clock, + &stake_history, + )?; + + // ensure all authorities match and lockups match if lockup is in force + MergeKind::metas_can_merge( + invoke_context, + source_merge_kind.meta(), + destination_merge_kind.meta(), + &clock, + )?; + + Ok((source_merge_kind, destination_merge_kind)) +} + pub(crate) fn new_stake( stake: u64, voter_pubkey: &Pubkey, @@ -705,6 +785,191 @@ pub fn redelegate( Ok(()) } +pub fn move_stake( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account_index: IndexOfAccount, + lamports: u64, + destination_account_index: IndexOfAccount, + stake_authority_index: IndexOfAccount, +) -> Result<(), InstructionError> { + let mut source_account = instruction_context + .try_borrow_instruction_account(transaction_context, source_account_index)?; + + let mut destination_account = instruction_context + .try_borrow_instruction_account(transaction_context, destination_account_index)?; + + let (source_merge_kind, destination_merge_kind) = move_stake_or_lamports_shared_checks( + invoke_context, + transaction_context, + instruction_context, + &source_account, + lamports, + &destination_account, + stake_authority_index, + )?; + + // ensure source and destination are the right size for the current version of StakeState + // this a safeguard in case there is a new version of the struct that cannot fit into an old account + if source_account.get_data().len() != StakeStateV2::size_of() + || destination_account.get_data().len() != StakeStateV2::size_of() + { + return Err(InstructionError::InvalidAccountData); + } + + // source must be fully active + let MergeKind::FullyActive(source_meta, mut source_stake) = source_merge_kind else { + return Err(InstructionError::InvalidAccountData); + }; + + let minimum_delegation = crate::get_minimum_delegation(invoke_context.get_feature_set()); + let source_effective_stake = source_stake.delegation.stake; + + // source cannot move more stake than it has, regardless of how many lamports it has + let source_final_stake = source_effective_stake + .checked_sub(lamports) + .ok_or(InstructionError::InvalidArgument)?; + + // unless all stake is being moved, source must retain at least the minimum delegation + if source_final_stake != 0 && source_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + // destination must be fully active or fully inactive + let destination_meta = match destination_merge_kind { + MergeKind::FullyActive(destination_meta, mut destination_stake) => { + // if active, destination must be delegated to the same vote account as source + if source_stake.delegation.voter_pubkey != destination_stake.delegation.voter_pubkey { + return Err(StakeError::VoteAddressMismatch.into()); + } + + let destination_effective_stake = destination_stake.delegation.stake; + let destination_final_stake = destination_effective_stake + .checked_add(lamports) + .ok_or(InstructionError::ArithmeticOverflow)?; + + // ensure destination meets miniumum delegation + // since it is already active, this only really applies if the minimum is raised + if destination_final_stake < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + merge_delegation_stake_and_credits_observed( + &mut destination_stake, + lamports, + source_stake.credits_observed, + )?; + + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, does not apply to active stakes + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; + + destination_meta + } + MergeKind::Inactive(destination_meta, _, _) => { + // if destination is inactive, it must be given at least the minimum delegation + if lamports < minimum_delegation { + return Err(InstructionError::InvalidArgument); + } + + let mut destination_stake = source_stake; + destination_stake.delegation.stake = lamports; + + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, is cleared when a stake is activated + destination_account.set_state(&StakeStateV2::Stake( + destination_meta, + destination_stake, + StakeFlags::empty(), + ))?; + + destination_meta + } + _ => return Err(InstructionError::InvalidAccountData), + }; + + if source_final_stake == 0 { + source_account.set_state(&StakeStateV2::Initialized(source_meta))?; + } else { + source_stake.delegation.stake = source_final_stake; + + // StakeFlags::empty() is valid here because the only existing stake flag, + // MUST_FULLY_ACTIVATE_BEFORE_DEACTIVATION_IS_PERMITTED, does not apply to active stakes + source_account.set_state(&StakeStateV2::Stake( + source_meta, + source_stake, + StakeFlags::empty(), + ))?; + } + + source_account.checked_sub_lamports(lamports)?; + destination_account.checked_add_lamports(lamports)?; + + // this should be impossible, but because we do all our math with delegations, best to guard it + if source_account.get_lamports() < source_meta.rent_exempt_reserve + || destination_account.get_lamports() < destination_meta.rent_exempt_reserve + { + ic_msg!( + invoke_context, + "Delegation calculations violated lamport balance assumptions" + ); + return Err(InstructionError::InvalidArgument); + } + + Ok(()) +} + +pub fn move_lamports( + invoke_context: &InvokeContext, + transaction_context: &TransactionContext, + instruction_context: &InstructionContext, + source_account_index: IndexOfAccount, + lamports: u64, + destination_account_index: IndexOfAccount, + stake_authority_index: IndexOfAccount, +) -> Result<(), InstructionError> { + let mut source_account = instruction_context + .try_borrow_instruction_account(transaction_context, source_account_index)?; + + let mut destination_account = instruction_context + .try_borrow_instruction_account(transaction_context, destination_account_index)?; + + let (source_merge_kind, _) = move_stake_or_lamports_shared_checks( + invoke_context, + transaction_context, + instruction_context, + &source_account, + lamports, + &destination_account, + stake_authority_index, + )?; + + let source_free_lamports = match source_merge_kind { + MergeKind::FullyActive(source_meta, source_stake) => source_account + .get_lamports() + .saturating_sub(source_stake.delegation.stake) + .saturating_sub(source_meta.rent_exempt_reserve), + MergeKind::Inactive(source_meta, source_lamports, _) => { + source_lamports.saturating_sub(source_meta.rent_exempt_reserve) + } + _ => return Err(InstructionError::InvalidAccountData), + }; + + if lamports > source_free_lamports { + return Err(InstructionError::InvalidArgument); + } + + source_account.checked_sub_lamports(lamports)?; + destination_account.checked_add_lamports(lamports)?; + + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub fn withdraw( transaction_context: &TransactionContext, diff --git a/sdk/program/src/stake/instruction.rs b/sdk/program/src/stake/instruction.rs index ec929864ffd6b0..e2a5b056e70618 100644 --- a/sdk/program/src/stake/instruction.rs +++ b/sdk/program/src/stake/instruction.rs @@ -307,6 +307,42 @@ pub enum StakeInstruction { /// 4. `[SIGNER]` Stake authority /// Redelegate, + + /// Move stake between accounts with the same authorities and lockups, using Staker authority. + /// + /// The source account must be fully active. If its entire delegation is moved, it immediately + /// becomes inactive. Otherwise, at least the minimum delegation of active stake must remain. + /// + /// The destination account must be fully active or fully inactive. If it is active, it must + /// be delegated to the same vote account as the source. If it is inactive, it + /// immediately becomes active, and must contain at least the minimum delegation. The + /// destination must be pre-funded with the rent-exempt reserve. + /// + /// This instruction only affects or moves active stake. Additional unstaked lamports are never + /// moved, activated, or deactivated, and accounts are never deallocated. + /// + /// # Account references + /// 0. `[WRITE]` Active source stake account + /// 1. `[WRITE]` Active or inactive destination stake account + /// 2. `[SIGNER]` Stake authority + /// + /// The u64 is the portion of the stake to move, which may be the entire delegation + MoveStake(u64), + + /// Move unstaked lamports between accounts with the same authorities and lockups, using Staker + /// authority. + /// + /// The source account must be fully active or fully inactive. The destination may be in any + /// mergeable state (active, inactive, or activating, but not in warmup cooldown). Only lamports that + /// are neither backing a delegation nor required for rent-exemption may be moved. + /// + /// # Account references + /// 0. `[WRITE]` Active or inactive source stake account + /// 1. `[WRITE]` Mergeable destination stake account + /// 2. `[SIGNER]` Stake authority + /// + /// The u64 is the portion of available lamports to move + MoveLamports(u64), } #[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)] @@ -847,6 +883,40 @@ pub fn redelegate_with_seed( ] } +pub fn move_stake( + source_stake_pubkey: &Pubkey, + destination_stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + lamports: u64, +) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*source_stake_pubkey, false), + AccountMeta::new(*destination_stake_pubkey, false), + AccountMeta::new_readonly(*authorized_pubkey, true), + ]; + + Instruction::new_with_bincode(id(), &StakeInstruction::MoveStake(lamports), account_metas) +} + +pub fn move_lamports( + source_stake_pubkey: &Pubkey, + destination_stake_pubkey: &Pubkey, + authorized_pubkey: &Pubkey, + lamports: u64, +) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*source_stake_pubkey, false), + AccountMeta::new(*destination_stake_pubkey, false), + AccountMeta::new_readonly(*authorized_pubkey, true), + ]; + + Instruction::new_with_bincode( + id(), + &StakeInstruction::MoveLamports(lamports), + account_metas, + ) +} + #[cfg(test)] mod tests { use {super::*, crate::instruction::InstructionError}; diff --git a/sdk/src/feature_set.rs b/sdk/src/feature_set.rs index e4970cf8cda0a3..4bfa63cd654ba4 100644 --- a/sdk/src/feature_set.rs +++ b/sdk/src/feature_set.rs @@ -820,6 +820,7 @@ pub mod migrate_config_program_to_core_bpf { pub mod enable_get_epoch_stake_syscall { solana_sdk::declare_id!("7mScTYkJXsbdrcwTQRs7oeCSXoJm4WjzBsRyf8bCU3Np"); } + pub mod migrate_address_lookup_table_program_to_core_bpf { solana_sdk::declare_id!("C97eKZygrkU4JxJsZdjgbUY7iQR7rKTr4NyDWo2E5pRm"); } @@ -828,6 +829,10 @@ pub mod zk_elgamal_proof_program_enabled { solana_sdk::declare_id!("zkhiy5oLowR7HY4zogXjCjeMXyruLqBwSWH21qcFtnv"); } +pub mod move_stake_and_move_lamports_ixs { + solana_sdk::declare_id!("7bTK6Jis8Xpfrs8ZoUfiMDPazTcdPcTWheZFJTA5Z6X4"); +} + lazy_static! { /// Map of feature identifiers to user-visible description pub static ref FEATURE_NAMES: HashMap = [ @@ -1030,6 +1035,7 @@ lazy_static! { (enable_get_epoch_stake_syscall::id(), "Enable syscall: sol_get_epoch_stake #884"), (migrate_address_lookup_table_program_to_core_bpf::id(), "Migrate Address Lookup Table program to Core BPF #1651"), (zk_elgamal_proof_program_enabled::id(), "Enable ZkElGamalProof program SIMD-0153"), + (move_stake_and_move_lamports_ixs::id(), "Enable MoveStake and MoveLamports stake program instructions #1610"), /*************** ADD NEW FEATURES HERE ***************/ ] .iter() diff --git a/transaction-status/src/parse_stake.rs b/transaction-status/src/parse_stake.rs index 8993a3eb57f95d..f1586e58694950 100644 --- a/transaction-status/src/parse_stake.rs +++ b/transaction-status/src/parse_stake.rs @@ -297,6 +297,30 @@ pub fn parse_stake( }), }) } + StakeInstruction::MoveStake(lamports) => { + check_num_stake_accounts(&instruction.accounts, 3)?; + Ok(ParsedInstructionEnum { + instruction_type: "moveStake".to_string(), + info: json!({ + "source": account_keys[instruction.accounts[0] as usize].to_string(), + "destination": account_keys[instruction.accounts[1] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[2] as usize].to_string(), + "lamports": lamports, + }), + }) + } + StakeInstruction::MoveLamports(lamports) => { + check_num_stake_accounts(&instruction.accounts, 3)?; + Ok(ParsedInstructionEnum { + instruction_type: "moveLamports".to_string(), + info: json!({ + "source": account_keys[instruction.accounts[0] as usize].to_string(), + "destination": account_keys[instruction.accounts[1] as usize].to_string(), + "stakeAuthority": account_keys[instruction.accounts[2] as usize].to_string(), + "lamports": lamports, + }), + }) + } } } @@ -309,6 +333,7 @@ mod test { use { super::*, solana_sdk::{ + instruction::Instruction, message::Message, pubkey::Pubkey, stake::{ @@ -1157,4 +1182,52 @@ mod test { message.instructions[0].accounts.pop(); assert!(parse_stake(&message.instructions[0], &AccountKeys::new(&keys, None)).is_err()); } + + #[test] + fn test_parse_stake_move_ix() { + let source_stake_pubkey = Pubkey::new_unique(); + let destination_stake_pubkey = Pubkey::new_unique(); + let authorized_pubkey = Pubkey::new_unique(); + let lamports = 1_000_000; + + type InstructionFn = fn(&Pubkey, &Pubkey, &Pubkey, u64) -> Instruction; + let test_vectors: Vec<(InstructionFn, String)> = vec![ + (instruction::move_stake, "moveStake".to_string()), + (instruction::move_lamports, "moveLamports".to_string()), + ]; + + for (mk_ixn, ixn_string) in test_vectors { + let instruction = mk_ixn( + &source_stake_pubkey, + &destination_stake_pubkey, + &authorized_pubkey, + lamports, + ); + let mut message = Message::new(&[instruction], None); + assert_eq!( + parse_stake( + &message.instructions[0], + &AccountKeys::new(&message.account_keys, None) + ) + .unwrap(), + ParsedInstructionEnum { + instruction_type: ixn_string, + info: json!({ + "source": source_stake_pubkey.to_string(), + "destination": destination_stake_pubkey.to_string(), + "stakeAuthority": authorized_pubkey.to_string(), + "lamports": lamports, + }), + } + ); + assert!(parse_stake( + &message.instructions[0], + &AccountKeys::new(&message.account_keys[0..2], None) + ) + .is_err()); + let keys = message.account_keys.clone(); + message.instructions[0].accounts.pop(); + assert!(parse_stake(&message.instructions[0], &AccountKeys::new(&keys, None)).is_err()); + } + } }