Skip to content

program: move to lamport-based accounting#601

Open
2501babe wants to merge 3 commits intosolana-program:mainfrom
2501babe:20260402_lampnev
Open

program: move to lamport-based accounting#601
2501babe wants to merge 3 commits intosolana-program:mainfrom
2501babe:20260402_lampnev

Conversation

@2501babe
Copy link
Copy Markdown
Member

@2501babe 2501babe commented Apr 3, 2026

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 DepositSol as described in #46, since liquid sol deposits will immediately become accountable value

closes #581

@2501babe 2501babe self-assigned this Apr 3, 2026
@2501babe 2501babe force-pushed the 20260402_lampnev branch 3 times, most recently from b24202f to cf872d1 Compare April 7, 2026 09:38
Comment on lines -50 to +68
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)
Copy link
Copy Markdown
Member Author

@2501babe 2501babe Apr 8, 2026

Choose a reason for hiding this comment

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

note the calculate_deposit_amount() and calculate_withdraw_amount() changes are just renaming variables

Comment on lines +793 to -778
let rent = Rent::get()?;
let stake_history = &StakeHistorySysvar(clock.epoch);
let minimum_delegation = stake::tools::get_minimum_delegation()?;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines -1091 to 1108
// 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)?;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines -1081 to 1103
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)?;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines +1220 to +1231
// 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)
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@2501babe 2501babe marked this pull request as ready for review April 8, 2026 03:32
@2501babe 2501babe requested review from grod220 and joncinque April 8, 2026 03:32
@2501babe
Copy link
Copy Markdown
Member Author

2501babe commented Apr 8, 2026

@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

Copy link
Copy Markdown
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.

Looks great overall! Just a question on the accounting in the test

Comment on lines -1091 to 1108
// 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)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +1220 to +1231
// 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)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +1255 to +1256
// 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() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Certainly can't hurt to check!

Comment on lines +1249 to +1253
// 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());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm having a hard time with these:

  • why does prior_deposit reduce 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 != 0 reduce 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());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

how about we go for a ReplenishRequired error here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess a large donation could also prevent a withdraw here and delay it to the next epoch?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we get a test for this?

calculate_withdraw_amount(token_supply, pre_total_nev, token_amount)
.ok_or(SinglePoolError::UnexpectedMathError)?;

// self-explanatory
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we get tests for these err exit paths?

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.

program: use all non-rent lamports for deposit/withdraw

3 participants