diff --git a/programs/stake/src/legacy_stake_state.rs b/programs/stake/src/legacy_stake_state.rs
index a1c2f8dda..2bde5830f 100644
--- a/programs/stake/src/legacy_stake_state.rs
+++ b/programs/stake/src/legacy_stake_state.rs
@@ -541,7 +541,7 @@ impl<'a> StakeAccount for KeyedAccount<'a> {
StakeState::Stake(meta, stake) => {
// stake must be fully de-activated
if stake.stake(clock.epoch, Some(stake_history)) != 0 {
- return Err(StakeError::MergeActivatedStake.into());
+ return Err(StakeError::MergeTransientStake.into());
}
meta
}
@@ -555,7 +555,7 @@ impl<'a> StakeAccount for KeyedAccount<'a> {
StakeState::Stake(meta, stake) => {
// stake must be fully de-activated
if stake.stake(clock.epoch, Some(stake_history)) != 0 {
- return Err(StakeError::MergeActivatedStake.into());
+ return Err(StakeError::MergeTransientStake.into());
}
meta
}
@@ -3239,7 +3239,7 @@ mod tests {
&StakeHistory::default(),
&signers,
),
- Err(StakeError::MergeActivatedStake.into())
+ Err(StakeError::MergeTransientStake.into())
);
}
}
diff --git a/programs/stake/src/stake_instruction.rs b/programs/stake/src/stake_instruction.rs
index cfe41bae6..ef60070ad 100644
--- a/programs/stake/src/stake_instruction.rs
+++ b/programs/stake/src/stake_instruction.rs
@@ -36,10 +36,10 @@ pub enum StakeError {
#[error("split amount is more than is staked")]
InsufficientStake,
- #[error("stake account with activated stake cannot be merged")]
- MergeActivatedStake,
+ #[error("stake account with transient stake cannot be merged")]
+ MergeTransientStake,
- #[error("stake account merge failed due to different authority or lockups")]
+ #[error("stake account merge failed due to different authority, lockups or state")]
MergeMismatch,
}
diff --git a/programs/stake/src/stake_state.rs b/programs/stake/src/stake_state.rs
index c5b2a3d35..e050913f1 100644
--- a/programs/stake/src/stake_state.rs
+++ b/programs/stake/src/stake_state.rs
@@ -242,6 +242,7 @@ impl Delegation {
.0
}
+ // returned tuple is (effective, activating, deactivating) stake
#[allow(clippy::comparison_chain)]
pub fn stake_activating_and_deactivating(
&self,
@@ -325,6 +326,7 @@ impl Delegation {
}
}
+ // returned tuple is (effective, activating) stake
fn stake_and_activating(
&self,
target_epoch: Epoch,
@@ -1055,16 +1057,16 @@ impl<'a> StakeAccount for KeyedAccount<'a> {
return Err(InstructionError::IncorrectProgramId);
}
- let meta = get_info_if_mergable(self, clock, stake_history)?;
+ let stake_merge_kind = MergeKind::get_if_mergeable(self, clock, stake_history)?;
+ let meta = stake_merge_kind.meta();
// Authorized staker is allowed to split/merge accounts
meta.authorized.check(signers, StakeAuthorize::Staker)?;
- let source_meta = get_info_if_mergable(source_account, clock, stake_history)?;
+ let source_merge_kind = MergeKind::get_if_mergeable(source_account, clock, stake_history)?;
- // Meta must match for both accounts
- if meta != source_meta {
- return Err(StakeError::MergeMismatch.into());
+ if let Some(merged_state) = stake_merge_kind.merge(source_merge_kind)? {
+ self.set_state(&merged_state)?;
}
// Drain the source stake account
@@ -1149,23 +1151,137 @@ impl<'a> StakeAccount for KeyedAccount<'a> {
}
}
-fn get_info_if_mergable(
- stake_keyed_account: &KeyedAccount,
- clock: &Clock,
- stake_history: &StakeHistory,
-) -> Result {
- match stake_keyed_account.state()? {
- StakeState::Stake(meta, stake) => {
- // stake must be fully de-activated
- if stake.stake(clock.epoch, Some(stake_history), true) != 0 {
- return Err(StakeError::MergeActivatedStake.into());
- }
- Ok(meta)
+#[derive(Clone, Debug, PartialEq)]
+enum MergeKind {
+ Inactive(Meta, u64),
+ ActivationEpoch(Meta, Stake),
+ FullyActive(Meta, Stake),
+}
+
+impl MergeKind {
+ fn meta(&self) -> &Meta {
+ match self {
+ Self::Inactive(meta, _) => meta,
+ Self::ActivationEpoch(meta, _) => meta,
+ Self::FullyActive(meta, _) => meta,
}
- StakeState::Initialized(meta) => Ok(meta),
- _ => Err(InstructionError::InvalidAccountData),
+ }
+
+ fn active_stake(&self) -> Option<&Stake> {
+ match self {
+ Self::Inactive(_, _) => None,
+ Self::ActivationEpoch(_, stake) => Some(stake),
+ Self::FullyActive(_, stake) => Some(stake),
+ }
+ }
+
+ fn get_if_mergeable(
+ stake_keyed_account: &KeyedAccount,
+ clock: &Clock,
+ stake_history: &StakeHistory,
+ ) -> Result {
+ match stake_keyed_account.state()? {
+ StakeState::Stake(meta, stake) => {
+ // stake must not be in a transient state. Transient here meaning
+ // activating or deactivating with non-zero effective stake.
+ match stake.delegation.stake_activating_and_deactivating(
+ clock.epoch,
+ Some(stake_history),
+ true,
+ ) {
+ /*
+ (e, a, d): e - effective, a - activating, d - deactivating */
+ (0, 0, 0) => Ok(Self::Inactive(meta, stake_keyed_account.lamports()?)),
+ (0, _, _) => Ok(Self::ActivationEpoch(meta, stake)),
+ (_, 0, 0) => Ok(Self::FullyActive(meta, stake)),
+ _ => Err(StakeError::MergeTransientStake.into()),
+ }
+ }
+ StakeState::Initialized(meta) => {
+ Ok(Self::Inactive(meta, stake_keyed_account.lamports()?))
+ }
+ _ => Err(InstructionError::InvalidAccountData),
+ }
+ }
+
+ fn metas_can_merge(stake: &Meta, source: &Meta) -> Result<(), InstructionError> {
+ // `rent_exempt_reserve` has no bearing on the mergeability of accounts,
+ // as the source account will be culled by runtime once the operation
+ // succeeds. Considering it here would needlessly prevent merging stake
+ // accounts with differing data lengths, which already exist in the wild
+ // due to an SDK bug
+ if stake.authorized == source.authorized && stake.lockup == source.lockup {
+ Ok(())
+ } else {
+ Err(StakeError::MergeMismatch.into())
+ }
+ }
+
+ fn active_delegations_can_merge(
+ stake: &Delegation,
+ source: &Delegation,
+ ) -> Result<(), InstructionError> {
+ if stake.voter_pubkey == source.voter_pubkey
+ && (stake.warmup_cooldown_rate - source.warmup_cooldown_rate).abs() < f64::EPSILON
+ && stake.deactivation_epoch == Epoch::MAX
+ && source.deactivation_epoch == Epoch::MAX
+ {
+ Ok(())
+ } else {
+ Err(StakeError::MergeMismatch.into())
+ }
+ }
+
+ fn active_stakes_can_merge(stake: &Stake, source: &Stake) -> Result<(), InstructionError> {
+ Self::active_delegations_can_merge(&stake.delegation, &source.delegation)?;
+ // `credits_observed` MUST match to prevent earning multiple rewards
+ // from a stake account by merging it into another stake account that
+ // is small enough to not be paid out every epoch. This would effectively
+ // reset the larger stake accounts `credits_observed` to that of the
+ // smaller account.
+ if stake.credits_observed == source.credits_observed {
+ Ok(())
+ } else {
+ Err(StakeError::MergeMismatch.into())
+ }
+ }
+
+ fn merge(self, source: Self) -> Result