-
Notifications
You must be signed in to change notification settings - Fork 32
program: rewrite process_split()
#90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
49c9214 to
879491e
Compare
879491e to
4dd0e7c
Compare
4dd0e7c to
66bd197
Compare
program/src/processor.rs
Outdated
| if source_stake_account_info.key != destination_stake_account_info.key { | ||
| source_stake_account_info.resize(0)?; | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!(), |
There was a problem hiding this comment.
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(),
}There was a problem hiding this comment.
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:
- all three account types can be split fully or partially. in master the big
matchsets everything up right for code after it to move lamports and optionally dealloc the source. if we returned from the bigmatchwed need to copy that logic in three places - inactive
Staketypes ought to be handled identically toInitialized. this means either we copy the inactive account logic in two places, or we bundle shared post-matchvalidation that depends onMetaa la the presentvalidate_split_amount()+ post-that extra math in theStakearm
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
There was a problem hiding this comment.
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!
| debug_assert!( | ||
| source_lamport_balance | ||
| .saturating_sub(split_lamports) | ||
| .saturating_sub(source_stake.delegation.stake) | ||
| >= source_meta.rent_exempt_reserve | ||
| ); |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
| return Err(ProgramError::InsufficientFunds); | ||
| } | ||
|
|
||
| set_stake_state(destination_stake_account_info, &destination_stake_state)?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
grod220
left a comment
There was a problem hiding this 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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
| let is_active_or_activating = | ||
| source_status.effective > 0 || source_status.activating > 0; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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!(), |
There was a problem hiding this comment.
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!
| if destination_lamport_balance < destination_rent_exempt_reserve { | ||
| return Err(ProgramError::InsufficientFunds); | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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());
}
program/src/processor.rs
Outdated
| if source_stake_account_info.key != destination_stake_account_info.key { | ||
| source_stake_account_info.resize(0)?; | ||
| } |
There was a problem hiding this comment.
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)?; |
There was a problem hiding this comment.
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)
|
@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 |
i rewrote
Splitfrom scratch to fix #1 plus some other bugs with minimum delegation. i also tried to make it more readable.Splithas 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 historycurrent
Splittriesmatchonce on source stake and share code for both theInitializedandStakecases in the unifiedvalidate_split_amount()function and then do more work for theStakecase after. it ends up being quite difficult to understand because:Stakeis deactivated it should actually follow the same logic asInitialized, not the logic forStakefeature_set::rent_exempt_split_destinationswas added in a minimally invasive wayas a consequence of all this, i find
Splitthe most difficult stake operation to reason about. in rewritingprocess_split(), i decided to:matching on stake states as convenient rather than forcing the code to be structured around a singlematchthis results in a function that has a bit more boilerplate but none of the difficult calculations of the orginal
Spliti also made it an error to split 0 from
Uninitialized. i dont see any good reason to allow itcloses #1