Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Main

on:
push:
branches: [main,mollusk-tests-5-withdraw]
branches: [main,mollusk-tests-6-merge]
pull_request:

env:
Expand Down
26 changes: 23 additions & 3 deletions program/tests/helpers/instruction_builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ impl<'b> InstructionExecution<'_, 'b> {
self
}

/// Executes the instruction. If `checks` is `None` or empty, uses `Check::success()`.
/// Executes the instruction. If `checks` is `None`, uses `Check::success()`.
/// If `checks` is `Some(&[])` (explicitly empty), performs no checks.
/// Fail-safe default: when `test_missing_signers` is `None`, runs the missing-signers
/// test (`true`). Callers must explicitly opt out with `.test_missing_signers(false)`.
pub fn execute(self) -> mollusk_svm::result::InstructionResult {
let default_checks = [Check::success()];
let checks = match self.checks {
Some(c) if !c.is_empty() => c,
_ => &default_checks,
Some(c) => c,
None => &default_checks,
};

let test_missing_signers = self.test_missing_signers.unwrap_or(true);
Expand Down Expand Up @@ -181,3 +182,22 @@ impl InstructionConfig for WithdrawConfig<'_> {
]
}
}

pub struct MergeConfig<'a> {
pub destination: (&'a Pubkey, &'a AccountSharedData),
pub source: (&'a Pubkey, &'a AccountSharedData),
}

impl InstructionConfig for MergeConfig<'_> {
fn build_instruction(&self, ctx: &StakeTestContext) -> Instruction {
let instructions = ixn::merge(self.destination.0, self.source.0, &ctx.staker);
instructions[0].clone() // Merge returns a Vec, use first instruction
}

fn build_accounts(&self) -> Vec<(Pubkey, AccountSharedData)> {
vec![
(*self.destination.0, self.destination.1.clone()),
(*self.source.0, self.source.1.clone()),
]
}
}
109 changes: 109 additions & 0 deletions program/tests/merge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#![allow(clippy::arithmetic_side_effects)]

mod helpers;

use {
helpers::{
context::StakeTestContext, instruction_builders::MergeConfig, lifecycle::StakeLifecycle,
},
mollusk_svm::result::Check,
solana_account::ReadableAccount,
solana_stake_interface::state::StakeStateV2,
solana_stake_program::id,
test_case::test_matrix,
};

#[test_matrix(
[StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating,
StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive],
[StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating,
StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive]
)]
fn test_merge(merge_source_type: StakeLifecycle, merge_dest_type: StakeLifecycle) {
let mut ctx = StakeTestContext::new();

let staked_amount = ctx.minimum_delegation;

// Determine if merge should be allowed based on lifecycle types
let is_merge_allowed_by_type = match (merge_source_type, merge_dest_type) {
// Inactive and inactive
(StakeLifecycle::Initialized, StakeLifecycle::Initialized)
| (StakeLifecycle::Initialized, StakeLifecycle::Deactive)
| (StakeLifecycle::Deactive, StakeLifecycle::Initialized)
| (StakeLifecycle::Deactive, StakeLifecycle::Deactive) => true,

// Activating into inactive is also allowed
(StakeLifecycle::Activating, StakeLifecycle::Initialized)
| (StakeLifecycle::Activating, StakeLifecycle::Deactive) => true,

// Inactive into activating
(StakeLifecycle::Initialized, StakeLifecycle::Activating)
| (StakeLifecycle::Deactive, StakeLifecycle::Activating) => true,

// Active and active
(StakeLifecycle::Active, StakeLifecycle::Active) => true,

// Activating and activating
(StakeLifecycle::Activating, StakeLifecycle::Activating) => true,

// Everything else fails
_ => false,
};

// Create source and dest accounts
let (merge_source, mut merge_source_account) = ctx
.stake_account(merge_source_type)
.staked_amount(staked_amount.unwrap())
.build();
let (merge_dest, merge_dest_account) = ctx
.stake_account(merge_dest_type)
.staked_amount(staked_amount.unwrap())
.build();

// Retrieve source data and sync epochs if needed
let mut source_stake_state: StakeStateV2 =
bincode::deserialize(merge_source_account.data()).unwrap();

let clock = ctx.mollusk.sysvars.clock.clone();
// Sync epochs for transient states
if let StakeStateV2::Stake(_, ref mut stake, _) = &mut source_stake_state {
match merge_source_type {
StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch,
StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch,
_ => (),
}
}

// Store updated source
merge_source_account.set_data(bincode::serialize(&source_stake_state).unwrap());

// Attempt to merge
if is_merge_allowed_by_type {
ctx.process_with(MergeConfig {
destination: (&merge_dest, &merge_dest_account),
source: (&merge_source, &merge_source_account),
})
.checks(&[
Check::success(),
Check::account(&merge_dest)
.lamports(staked_amount.unwrap() * 2 + ctx.rent_exempt_reserve * 2)
.owner(&id())
.space(StakeStateV2::size_of())
.rent_exempt()
.build(),
])
.test_missing_signers(true)
.execute();
} else {
// Various errors can occur for invalid merges, we just check it fails
let result = ctx
.process_with(MergeConfig {
destination: (&merge_dest, &merge_dest_account),
source: (&merge_source, &merge_source_account),
})
.checks(&[]) // Skip Success check
.test_missing_signers(false)
.execute();
assert!(result.program_result.is_err());
}
}
121 changes: 0 additions & 121 deletions program/tests/program_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -750,127 +750,6 @@ impl StakeLifecycle {
}
}

// XXX the original test_merge is a stupid test
// the real thing is test_merge_active_stake which actively controls clock and
// stake_history but im just trying to smoke test rn so lets do something
// simpler
#[test_matrix(
[StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating,
StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive],
[StakeLifecycle::Uninitialized, StakeLifecycle::Initialized, StakeLifecycle::Activating,
StakeLifecycle::Active, StakeLifecycle::Deactivating, StakeLifecycle::Deactive]
)]
#[tokio::test]
async fn program_test_merge(merge_source_type: StakeLifecycle, merge_dest_type: StakeLifecycle) {
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;
let staked_amount = minimum_delegation;

// stake accounts can be merged unconditionally:
// * inactive and inactive
// * inactive into activating
// can be merged IF vote pubkey and credits match:
// * active and active
// * activating and activating, IF activating in the same epoch
// in all cases, authorized and lockup also must match
// uninitialized stakes cannot be merged at all
let is_merge_allowed_by_type = match (merge_source_type, merge_dest_type) {
// inactive and inactive
(StakeLifecycle::Initialized, StakeLifecycle::Initialized)
| (StakeLifecycle::Initialized, StakeLifecycle::Deactive)
| (StakeLifecycle::Deactive, StakeLifecycle::Initialized)
| (StakeLifecycle::Deactive, StakeLifecycle::Deactive) => true,

// activating into inactive is also allowed although this isnt clear from docs
(StakeLifecycle::Activating, StakeLifecycle::Initialized)
| (StakeLifecycle::Activating, StakeLifecycle::Deactive) => true,

// inactive into activating
(StakeLifecycle::Initialized, StakeLifecycle::Activating)
| (StakeLifecycle::Deactive, StakeLifecycle::Activating) => true,

// active and active
(StakeLifecycle::Active, StakeLifecycle::Active) => true,

// activating and activating
(StakeLifecycle::Activating, StakeLifecycle::Activating) => true,

// better luck next time
_ => false,
};

// create source first
let (merge_source_keypair, _, _) = merge_source_type
.new_stake_account(&mut context, &accounts.vote_account.pubkey(), staked_amount)
.await;
let merge_source = merge_source_keypair.pubkey();

// retrieve its data
let mut source_account = get_account(&mut context.banks_client, &merge_source).await;
let mut source_stake_state: StakeStateV2 = bincode::deserialize(&source_account.data).unwrap();

// create dest. this may mess source up if its in a transient state, but its
// fine
let (merge_dest_keypair, staker_keypair, withdrawer_keypair) = merge_dest_type
.new_stake_account(&mut context, &accounts.vote_account.pubkey(), staked_amount)
.await;
let merge_dest = merge_dest_keypair.pubkey();

// now we change source authorized to match dest
// we can also true up the epoch if source should have been transient
let clock = context.banks_client.get_sysvar::<Clock>().await.unwrap();
match &mut source_stake_state {
StakeStateV2::Initialized(ref mut meta) => {
meta.authorized.staker = staker_keypair.pubkey();
meta.authorized.withdrawer = withdrawer_keypair.pubkey();
}
StakeStateV2::Stake(ref mut meta, ref mut stake, _) => {
meta.authorized.staker = staker_keypair.pubkey();
meta.authorized.withdrawer = withdrawer_keypair.pubkey();

match merge_source_type {
StakeLifecycle::Activating => stake.delegation.activation_epoch = clock.epoch,
StakeLifecycle::Deactivating => stake.delegation.deactivation_epoch = clock.epoch,
_ => (),
}
}
_ => (),
}

// and store
source_account.data = bincode::serialize(&source_stake_state).unwrap();
context.set_account(&merge_source, &source_account.into());

// attempt to merge
let instruction = ixn::merge(&merge_dest, &merge_source, &staker_keypair.pubkey())
.into_iter()
.next()
.unwrap();

// failure can result in various different errors... dont worry about it for now
if is_merge_allowed_by_type {
process_instruction_test_missing_signers(
&mut context,
&instruction,
&vec![&staker_keypair],
)
.await;

let dest_lamports = get_account(&mut context.banks_client, &merge_dest)
.await
.lamports;
assert_eq!(dest_lamports, staked_amount * 2 + rent_exempt_reserve * 2);
} else {
process_instruction(&mut context, &instruction, &vec![&staker_keypair])
.await
.unwrap_err();
}
}

#[test_matrix(
[StakeLifecycle::Initialized, StakeLifecycle::Activating, StakeLifecycle::Active,
StakeLifecycle::Deactivating, StakeLifecycle::Deactive],
Expand Down