program: move to lamport-based accounting#601
program: move to lamport-based accounting#6012501babe wants to merge 3 commits intosolana-program:mainfrom
Conversation
b24202f to
cf872d1
Compare
75c2976 to
e1b5eb6
Compare
| if pre_pool_stake == 0 || pre_token_supply == 0 { | ||
| Some(user_stake_to_deposit) | ||
| if pre_pool_nev == 0 || pre_token_supply == 0 { | ||
| Some(user_deposit_amount) |
There was a problem hiding this comment.
note the calculate_deposit_amount() and calculate_withdraw_amount() changes are just renaming variables
| let rent = Rent::get()?; | ||
| let stake_history = &StakeHistorySysvar(clock.epoch); | ||
| let minimum_delegation = stake::tools::get_minimum_delegation()?; |
There was a problem hiding this comment.
these changes to replenish are mostly just style to align with the other functions, though moving get_minimum_delegation() after check_stake_program() is technically a correctness improvement
| // if there were excess lamports in the user-provided account, we return them | ||
| // this includes their rent-exempt reserve if the pool is fully active | ||
| let user_excess_lamports = post_pool_lamports | ||
| .checked_sub(pre_pool_lamports) | ||
| .and_then(|amount| amount.checked_sub(stake_added)) | ||
| // return user lamports that were not added to stake | ||
| let user_excess_lamports = pre_user_lamports | ||
| .checked_sub(new_stake_added) | ||
| .ok_or(SinglePoolError::ArithmeticOverflow)?; |
There was a problem hiding this comment.
this is like the third version of the user_excess_lamports calculation and while it feels strange to calculate from the user rather than from the pool i can find no reason this isnt correct
There was a problem hiding this comment.
It's a little strange, but we should be able to trust the stake program to do the right thing.
The only use-case that could be worrying is a multi-epoch activation, but those don't allow merges anyway.
There was a problem hiding this comment.
because of #586 we know via stake history the pool account is either 100% activating or 100% active, and the user account is 100% active (with pool active), or 100% activating or inactive (with pool activating), otherwise we abort before we call Merge. so this is perhaps less complicated than it feels and we dont rely on Merge to reject it
| let (_, pool_stake_state) = get_stake_state(pool_stake_info)?; | ||
| let post_pool_stake = pool_stake_state.delegation.stake; | ||
| let post_pool_lamports = pool_stake_info.lamports(); | ||
| msg!("Available stake post merge {}", post_pool_stake); | ||
|
|
||
| // stake lamports added, as a stake difference | ||
| let stake_added = post_pool_stake | ||
| // determine new stake lamports added by merge | ||
| let post_pool_stake = get_stake_amount(pool_stake_info)?; | ||
| let new_stake_added = post_pool_stake | ||
| .checked_sub(pre_pool_stake) | ||
| .ok_or(SinglePoolError::ArithmeticOverflow)?; |
There was a problem hiding this comment.
the new deposit math feels straightforward to me but this is an important thing to scrutinize
we have already asserted the pool is fully active or newly activating. then we get NEV ie stakeable lamport sum of both pool accounts. we assert user stake is in a happy state and merge. we simply get pool stake delegation delta by subtracting pre from post. and then user excess lamports are pre-user lamports minus new stake. we then use new stake to calculate pool tokens
| // if fully inactive, we split on lamports; otherwise, on all delegation. | ||
| // the stake program works off delegation in this way *even* for a partially deactivated stake | ||
| if pool_stake_status == StakeActivationStatus::default() { | ||
| ( | ||
| pool_stake_info | ||
| .lamports() | ||
| .saturating_sub(rent.minimum_balance(pool_stake_info.data_len())), | ||
| true, | ||
| ) | ||
| } else { | ||
| (pool_stake_state.delegation.stake, false) | ||
| } |
There was a problem hiding this comment.
this is quite touchy but i believe it to be correct. if there is any activating, deactivating, or effective stake then the delegation is meaningful, and its full value is always used, so we calculate off delegation. for a fully inactive stake we must calculate off lamports
the withdraw process gets pool NEV and uses that to calculate the amount to pass to the stake program, which does not depend on stake delegation. "withdrawable value" is just a guard for friendly error messages
There was a problem hiding this comment.
This looks correct to me -- there's weirdness in the activating case, since in some situations we do treat those as normal lamports, like merge, but split doesn't do that.
|
@joncinque @grod220 id appreciate if you could both review since this pr is definitely the highest risk change of the various things ive done the past couple weeks to this program |
joncinque
left a comment
There was a problem hiding this comment.
Looks great overall! Just a question on the accounting in the test
| // if there were excess lamports in the user-provided account, we return them | ||
| // this includes their rent-exempt reserve if the pool is fully active | ||
| let user_excess_lamports = post_pool_lamports | ||
| .checked_sub(pre_pool_lamports) | ||
| .and_then(|amount| amount.checked_sub(stake_added)) | ||
| // return user lamports that were not added to stake | ||
| let user_excess_lamports = pre_user_lamports | ||
| .checked_sub(new_stake_added) | ||
| .ok_or(SinglePoolError::ArithmeticOverflow)?; |
There was a problem hiding this comment.
It's a little strange, but we should be able to trust the stake program to do the right thing.
The only use-case that could be worrying is a multi-epoch activation, but those don't allow merges anyway.
| // if fully inactive, we split on lamports; otherwise, on all delegation. | ||
| // the stake program works off delegation in this way *even* for a partially deactivated stake | ||
| if pool_stake_status == StakeActivationStatus::default() { | ||
| ( | ||
| pool_stake_info | ||
| .lamports() | ||
| .saturating_sub(rent.minimum_balance(pool_stake_info.data_len())), | ||
| true, | ||
| ) | ||
| } else { | ||
| (pool_stake_state.delegation.stake, false) | ||
| } |
There was a problem hiding this comment.
This looks correct to me -- there's weirdness in the activating case, since in some situations we do treat those as normal lamports, like merge, but split doesn't do that.
| // this is theoretically impossible but we guard because it would put the pool in an unrecoverable state | ||
| if stake_to_withdraw == pool_stake_info.lamports() { |
There was a problem hiding this comment.
Certainly can't hurt to check!
| // if we do not have enough value to service this withdrawal, the user must wait a `ReplenishPool` cycle. | ||
| // this does *not* mean the value isnt in the pool, merely that it is not duly splittable | ||
| if stake_to_withdraw > withdrawable_value { | ||
| return Err(SinglePoolError::WithdrawalTooLarge.into()); | ||
| } |
There was a problem hiding this comment.
I wonder if it's worth outputting some message in this case, so that people know that their funds aren't locked, and what they need to do.
That could also be addressed in the future whenever there's a WithdrawSol instruction
| expected_deposit - 11 | ||
| } else { | ||
| expected_deposit - 10 | ||
| }; |
There was a problem hiding this comment.
Sorry, I'm having a hard time with these:
- why does
prior_depositreduce the expected deposit by 1 token? Is it due to truncation during calculation? - the one token difference between the last two branches makes sense -- if it's active, then you don't get tokens for your refunded rent, which probably gives that 1 token difference. Why does
!prior_deposit && pool_extra_lamports != 0reduce the tokens received by 10 though? Does the truncation just get bigger because there are fewer tokens in the pool?
Do you think you could add some comments for these or (arguably) better, rewrite these situations in terms of a match statement?
| // if we do not have enough value to service this withdrawal, the user must wait a `ReplenishPool` cycle. | ||
| // this does *not* mean the value isnt in the pool, merely that it is not duly splittable | ||
| if stake_to_withdraw > withdrawable_value { | ||
| return Err(SinglePoolError::WithdrawalTooLarge.into()); |
There was a problem hiding this comment.
how about we go for a ReplenishRequired error here?
There was a problem hiding this comment.
I guess a large donation could also prevent a withdraw here and delay it to the next epoch?
There was a problem hiding this comment.
more importantly a large DepositSol when that is added in the next pr, its highly unlikely this would be hit any other way (youd need a huge transfer in or a pool with nearly no value, since mev rewards are such a small fraction of stake). i plan for sol deposit to require post hoc replenish
we discussed a bit here #46, i had the idea of capping sol deposits to a % of nev to prevent the most annoying case, a large deposit that is instantly withdrawn, delaying other withdrawals an epoch. but i think jon is right to just not overcomplicate things by introducing that
essentially the problem is unavoidable in some form or another since by introducing DepositSol the pool begins selling instant activation as a service. but i also noted this is probably not that big a deal since:
- after realizing this i decided to charge 10x more for
DepositSol - the service on offer is instant activation, not instant deactivation. you can imagine the market paying any price for the latter during a high volatility event, but its hard to imagine something that would make people say "shit!! xyz just happened!!! i need my sol to get more illiquid NOW!!!"
|
|
||
| // if fully inactive, we split on lamports; otherwise, on all delegation. | ||
| // the stake program works off delegation in this way *even* for a partially deactivated stake | ||
| if pool_stake_status == StakeActivationStatus::default() { |
| calculate_withdraw_amount(token_supply, pre_total_nev, token_amount) | ||
| .ok_or(SinglePoolError::UnexpectedMathError)?; | ||
|
|
||
| // self-explanatory |
There was a problem hiding this comment.
Can we get tests for these err exit paths?
in #580 we made the onramp mandatory in deposit and withdraw, after six months where it was added as an optional account. in #582 we changed from "1sol locked stake, hidden from calculations" to "1sol locked stake, used in calculations, alongside 1b fake tokens." in #586 we used stake history to fully ban deposits into any pool that is not fully active or newly activating, to eliminate thinking about weird edge cases
now this pr changes the fundamental unit of value for svsp from "active or activating stake in the main account" to "non-rent lamport in either the main or the onramp account." this clears the way to implement
DepositSolas described in #46, since liquid sol deposits will immediately become accountable valuecloses #581