Skip to content

Conversation

@2501babe
Copy link
Member

@2501babe 2501babe commented Oct 2, 2025

i rewrote Split from scratch to fix #1 plus some other bugs with minimum delegation. i also tried to make it more readable. Split has gone through several layers of changes which caused it to accumulate a lot of cruft, particularly because it originally ignored delegation, worked with lamports exclusively, and never used stake history

current Split tries match once on source stake and share code for both the Initialized and Stake cases in the unified validate_split_amount() function and then do more work for the Stake case after. it ends up being quite difficult to understand because:

  • when Stake is deactivated it should actually follow the same logic as Initialized, not the logic for Stake
  • logic for the full split is woven into all the same math as the partial split
  • there are many duplicated or "just in case" balance checks, particularly involving rent-exemption because feature_set::rent_exempt_split_destinations was added in a minimally invasive way
  • the math overall is extremely indirect. it starts with source/dest/split amount lamps and "additional required" (minimum delegation), calculates minimums from rent and additional, calculates source remaining from source and split amount, then uses these to try to calculate stake delta and split stake amounts. all through this, these values are combined and compared haphazardly to check various error conditions

as a consequence of all this, i find Split the most difficult stake operation to reason about. in rewriting process_split(), i decided to:

  • check signers up front for all cases
  • handle the full split, partial split inactive, and partial split active/activating cases separately, with their own math and update logic
  • tolerate matching on stake states as convenient rather than forcing the code to be structured around a single match

this results in a function that has a bit more boilerplate but none of the difficult calculations of the orginal Split

i also made it an error to split 0 from Uninitialized. i dont see any good reason to allow it

closes #1

@2501babe 2501babe marked this pull request as ready for review December 8, 2025 13:35
@2501babe 2501babe requested a review from grod220 December 8, 2025 13:35
Comment on lines 602 to 604
if source_stake_account_info.key != destination_stake_account_info.key {
source_stake_account_info.resize(0)?;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

source_stake_account_info.key == destination_stake_account_info.key is a strange case. Should we just guard above to throw an err in that case? Can't think of a reason why someone would do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dunno why we support it either tbh, other than that we always have and that there are tests for it which indicate it was on purpose. it could be that the original authors just had a preference for permissiveness

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just kill this? It feels more like a potential footgun hanging around.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0
}
(StakeStateV2::Uninitialized, None) => 0,
_ => unreachable!(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the unreachable!() code paths, I wonder if it would be easier if you kept the shared checks at the top, matched based on the source state, and then split into three separate code paths that end independently. At the moment, all three paths are sort of handled in parallel. The alternative would be something like:

// shared validation

return match source_stake_state {
    StakeStateV2::Stake() => {
        // A. Full split
        // B. Inactive partial split
        // C. Active partial split
    }
    StakeStateV2::Initialized() => {
        // Move lamports
    }
    StakeStateV2::Uninitialized => {
        // Move lamports
    }
    _ => Err(),
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats almost how process_split() is presently structured, and i think conforming to a "match once" pattern is the major cause of problems with Split, but maybe im insufficiently imaginative

the thing is:

  1. all three account types can be split fully or partially. in master the big match sets everything up right for code after it to move lamports and optionally dealloc the source. if we returned from the big match wed need to copy that logic in three places
  2. inactive Stake types ought to be handled identically to Initialized. this means either we copy the inactive account logic in two places, or we bundle shared post-match validation that depends on Meta a la the present validate_split_amount() + post-that extra math in the Stake arm

my thinking is the full split case is basically the same for all account types and the partial inactive split is the same for all account types, so get them out of the way. then handling the most inherently complex case, partial split of an active stake, is readable and straightforward

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. Didn't see the previous duplication. Thanks for the extra context!

Comment on lines 672 to 677
debug_assert!(
source_lamport_balance
.saturating_sub(split_lamports)
.saturating_sub(source_stake.delegation.stake)
>= source_meta.rent_exempt_reserve
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions are cleaned out in a release build right? Do we usually keep them in the code or are they just for local dev (aka removed before shipping)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea these dont get built in release. debug_assert! is generally used sparingly, you see it in agave sometimes too. i dont think theres a standard style guideline other than "it should always be irrefutable" but i sometimes throw it in for tricky calculations like "ok i did this one way, assert it via a different route to make sure the logic is never refactored to be unsound." im also fine deleting it tho

_ => unreachable!(),
}

let post_source_lamports = source_lamport_balance.saturating_sub(split_lamports);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if in general it's safer to transition most of these saturating methods to be checked versions. It's currently relying upon a downstream check of some kind. If it were checked it would raise early. What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont have a strong preference, i just did it this was since we would just map to the same error we return immediately below

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an earlier check that throws if split_lamports > source_lamport_balance, so I would go a step further and make this source_lamport_balance.checked_sub(split_lamports).unwrap() since we know for sure this is ok

Copy link
Member Author

@2501babe 2501babe Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh i like that the program code has zero unwrap()s...

edit: as noted below this is now checked_sub()

return Err(ProgramError::InsufficientFunds);
}

set_stake_state(destination_stake_account_info, &destination_stake_state)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to recompute to avoid delegation.stake from being out of sync (in both the source and destination)? If so, think we'd need to update it here and add some assertions in tests for delegate.stake values.

Copy link
Member Author

@2501babe 2501babe Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stake program historically has had the philosophy "if a stake is inactive its delegation.stake value is without meaning." the last time i talked to foundation about whether this was a ux issue was two months ago, they said theyd let me know if anyone had an issue with it in practice and havent pinged me about it since

we wouldnt want to recompute it, if we were going to change it we would probably want to reset deactivated stakes to Initialized in Split, Withdraw, and MoveLamports. but i think its debatable... the thing is that since an account can be deactivated and then not used again, and since we wouldnt want to rewrite account state if some preliminary checks fail, it is potentially a worse footgun if someone doesnt realize that stake accounts are often but not invariably reset to an Initialized state

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. With new eyes, it's hard to know a field like that is inert depending on other fields. But helpful to know it's safe.

(insert stake activation state machine pitch)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically delegation.stake never means anything on its own. it is only in combination with activation epoch, deactivation epoch, clock, and stake history that you know whether there is any stake or not. its just that a lot of consumers play "good enough for government work" with it

Copy link
Member

@grod220 grod220 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answers have been helpful 🙏

Just a few more questions.

destination_stake_account_info,
split_lamports,
)?;
debug_assert!(source_stake_account_info.lamports() == 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this assertion fail for a full split into itself?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

youre right, but i agree now on just removing self-split so ill do that instead

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the debug_asserts ever even used? Since program builds are optimized, they would only ever get run if someone calls process_split or process directly in a test, which I don't see in any of the tests, since they either use program-test or mollusk.

If that's the case, I would prefer to add these asserts directly to the tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yes, i was thinking about tests being debug but there is no debug build for sbf

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3859a55 there isnt really a way to test this since it can never happen, but i changed the tricky debug_assert! to a normal error because i like having the refactor safety of it. i removed the one this comment points to, its trivially true because we are in a if split_lamports == source_lamport_balance { ... } block

Comment on lines +536 to +537
let is_active_or_activating =
source_status.effective > 0 || source_status.activating > 0;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an aside: I wish activation status was an enum and not inferred state based on integer values of fields. We are relying upon proper handling to not end up with say effective == 0, activating == 0, deactivating > 0. But if we leveraged the type system, it would be an impossible state (aka finite state machine pattern).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea something like this is what i was envisioning with #75, im pretty sure no one who deals with this regularly has made zero lifetime mistakes with it, myself included. i took an abortive attempt at it a couple months ago but dont remember if i ran into a serious problem or if i just had something higher priority i had to drop it for

0
}
(StakeStateV2::Uninitialized, None) => 0,
_ => unreachable!(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. Didn't see the previous duplication. Thanks for the extra context!

Comment on lines +657 to +659
if destination_lamport_balance < destination_rent_exempt_reserve {
return Err(ProgramError::InsufficientFunds);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post relocate_lamports() below, should we be doing this check for the source account as well?

Copy link
Member Author

@2501babe 2501babe Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it can never be violated unless DelegateStake is unsound. this is what i meant when i called this...

// minimum delegation is by definition nonzero, and we remove one delegated lamport per split lamport
// since the remaining source delegation > 0, it is impossible that we took from its rent-exempt reserve
debug_assert!(
    source_lamport_balance
        .saturating_sub(split_lamports)
        .saturating_sub(source_stake.delegation.stake)
        >= source_meta.rent_exempt_reserve
);

...irrefutable

the check on destination rent is to ensure the account is pre-funded per what used to be feature_set::rent_exempt_split_destinations. then we take split_lamports directly from the source delegation, and enforce the delegation remains above the minimum delegation (presently 1 lamport). rent lamports, or any other undelegated lamports, never move in a partial active split

that is, the important check is this one

if source_stake.delegation.stake < minimum_delegation {
    return Err(StakeError::InsufficientDelegation.into());
}

Comment on lines 602 to 604
if source_stake_account_info.key != destination_stake_account_info.key {
source_stake_account_info.resize(0)?;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just kill this? It feels more like a potential footgun hanging around.

return Err(ProgramError::InsufficientFunds);
}

set_stake_state(destination_stake_account_info, &destination_stake_state)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. With new eyes, it's hard to know a field like that is inert depending on other fields. But helpful to know it's safe.

(insert stake activation state machine pitch)

grod220
grod220 previously approved these changes Dec 15, 2025
@2501babe 2501babe requested a review from joncinque December 16, 2025 10:40
@2501babe
Copy link
Member Author

@joncinque feel free to review as much or as little as you like, the main reason i added you is to sign off on banning self-split, in case there is deep lore on why it is allowed in the first place

Copy link
Contributor

@joncinque joncinque left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the slow review, but this looks great! I'm a big fan of the more linear flow with less nesting.

Mostly small questions and nits, nothing showstopping

Comment on lines +497 to +498
if source_stake_account_info.key == destination_stake_account_info.key {
return Err(ProgramError::InvalidArgument);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a reason of doing this, I'm happy to kill it

return Err(ProgramError::InsufficientFunds);
}

let source_rent_exempt_reserve = rent.minimum_balance(source_stake_account_info.data_len());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm pretty sure I've asked before, but I can't remember the answer: are we sure that all stake accounts have the correct meta.rent_exempt_reserve? The old code was using the field instead. If all stake accounts are correct, then great! I'm happy to start soft-deprecating rent_exempt_reserve

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we take it for granted that meta.rent_exempt_reserve is always correct, i just do it this way because source can be Uninitialized. rereading the version in master, i think this condition was never checked for that case, allowing a split to leave an uninitialized source below rent exemption and letting the instruction die on validator postchecks

1135c6d however i noticed i only need this value in the partial inactive block so i moved it inside there

// this prevents state changes in exceptional cases where a once-valid source has become invalid
// relocate lamports, copy data, and close the original account
if split_lamports == source_lamport_balance {
let mut destination_stake_state = source_stake_state;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since StakeStateV2 is Copy, this performs a copy, even though source_stake_state isn't used afterwards for this branch. I don't feel that strongly about it though, so it's probably something we can optimize later by just getting a mut reference to source_stake_state. Does it matter enough to warrant a comment?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mm i think im happy leaving it as-is, a 200 byte memset isnt a big deal compared to the cleanliness of letting source_stake_state be immutable

let mut destination_stake_state = source_stake_state;
let delegation = match (&mut destination_stake_state, option_dest_meta) {
(StakeStateV2::Stake(meta, stake, _), Some(dest_meta)) => {
*meta = dest_meta;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding this correctly, option_dest_meta just copies source_meta and updates rent_exempt_reserve, and then this code is copying the modified meta back into source_stake_state.

So if rent_exempt_reserve is the same (and correct) on all accounts, this part shouldn't be necessary. Do I have that right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, option_dest_meta is the source meta with the destination rent exemption, which we write into the new destination stake state. this exists to preserve the logic in master...

                let mut destination_meta = source_meta;
                destination_meta.rent_exempt_reserve =
                    validated_split_info.destination_rent_exempt_reserve;

...which i interpreted as a mechanism to allow splitting from a StakeStateV2 to a hypothetical larger StakeStateV3. theres an assert in a test test_split_rent_exemptness() from old monorepo that requires it

destination_stake_account_info,
split_lamports,
)?;
debug_assert!(source_stake_account_info.lamports() == 0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the debug_asserts ever even used? Since program builds are optimized, they would only ever get run if someone calls process_split or process directly in a test, which I don't see in any of the tests, since they either use program-test or mollusk.

If that's the case, I would prefer to add these asserts directly to the tests

_ => unreachable!(),
}

let post_source_lamports = source_lamport_balance.saturating_sub(split_lamports);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an earlier check that throws if split_lamports > source_lamport_balance, so I would go a step further and make this source_lamport_balance.checked_sub(split_lamports).unwrap() since we know for sure this is ok

Comment on lines 631 to 632
let post_destination_lamports =
destination_lamport_balance.saturating_add(split_lamports);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since split_lamports is checked earlier, this can't overflow, but let's do checked_add (with unwrap if you want) for this one too

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +672 to +673
// minimum delegation is by definition nonzero, and we remove one delegated lamport per split lamport
// since the remaining source delegation > 0, it is impossible that we took from its rent-exempt reserve
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you end up removing the debug assert, can you keep this comment? All the safety hinges on it 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as noted above i kept this as a normal error now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Splitting a deactivated stake wrongly takes delegation into account

3 participants