We’ve audited a liquid staking program forked from a well-known Solana stake pool implementation. Users deposit SOL into a shared pool and receive LP tokens (pool tokens) representing their proportional ownership. The deposited SOL is delegated across validators. When users want to exit, they burn their LP tokens and receive back their share of staked SOL, including any accrued rewards.
The Initial Flow
The original withdrawal flow works like this:
- The user specifies how many LP tokens (lp_tokens) they want to redeem.
fn process_withdraw_stake(
....
lp_tokens: u64,
....
) -> Result {
- The protocol deducts the withdrawal fee from that amount, producing lp_tokens_to_burn , the actual number of tokens that will be burned.
let lp_tokens_to_burn = lp_tokens
.checked_sub(lp_tokens_fee)
.ok_or(PoolError::CalculationFailure)?;
- lp_tokens_to_burn is then converted into withdraw_lamports via calc_lamports_for_withdrawal, the equivalent SOL value at the current exchange rate, rounded in the protocol's favour so the user receives slightly less in edge cases(This is good, for now).
let mut withdraw_lamports = pool
.calc_lamports_for_withdrawal(lp_tokens_to_burn)
.ok_or(PoolError::CalculationFailure)?;
- The protocol withdraws the SOL amount as stake from the pool and burns exactly lp_tokens_to_burn.
invoke_signed(
&burn(
token_program_info.key,
burn_from_pool_info.key,
pool_mint_info.key,
signer_or_session_info.key,
Some(program_signer_info.key),
lp_tokens_to_burn,
)?,
The Original Finding: Users Overpay on Withdrawal
During the audit, we identified an issue in the newly introduced logic of this withdrawal flow.
When a user withdraws stake via a new path, the protocol creates a PDA stake account to temporarily hold the user's withdrawn stake.
On Solana, every account must hold a minimum balance to exist (called rent).
So this PDA needs to be funded with stake_rent lamports just to stay alive.
The question is: Who pays that rent?
Scenario A:
A project native paymaster pays. Since the project covers the rent upfront, the user receives fewer lamports (split_lamports instead of withdraw_lamports).
let split_lamports = withdraw_lamports.saturating_sub(stake_rent);
Scenario B:
The user pays. The user sends stake_rent lamports directly to the PDA.
The problem is that code doesn't distinguish between these two scenarios.
Regardless of who pays the rent, the protocol always burns pool tokens based on withdraw_lamports, the full amount that includes rent.
But the actual lamports received into the user's account is only split_lamports, which equals withdraw_lamports - stake_rent.
So when the user is the one paying rent, they lose value on both sides: they transfer SOL for rent directly, and the protocol burns their LP shares as if that rent was part of the stake they received.
Our recommendation:
Check whether the payer is the user. If so, only burn split_lamports worth of LP shares tokens.
If a paymaster funded the PDA, burn withdraw_lamports , as it already works.
The team implemented this.
The fix seemed clean.
But the devil was hiding in the math.
The Fix That Introduced the Error
As we said, in both scenarios the user only receives split_lamports worth of actual stake, but the protocol burns the full lp_tokens_to_burn, which was derived from withdraw_lamports (a larger amount that includes rent).
When the user is the one paying rent, they're being overcharged.
If the user is the payer (Scenario B), don't burn based on withdraw_lamports, burn based on split_lamports instead, since that's what they actually received.
This means converting split_lamports back into a LP token amount, that will be actually burned
And that's exactly what the team did.
They took split_lamports and reverse-converted it into LP tokens using calc_lp_tokens_for_deposit:
pub fn calc_lp_tokens_for_deposit(&self, stake_lamports: u64) -> Option<u64> {
...
u64::try_from(
(stake_lamports as u128)
.checked_mul(self.lp_token_supply as u128)?
.checked_div(self.total_lamports as u128)?,
)
.ok()
}
The logic reads correctly: if the user paid rent, burn tokens for split_lamports only.
If a paymaster paid, burn tokens for lp_tokens_to_burn.
But the problem is in the function they used for that reverse conversion.
The Double Rounding Problem
This fix introduced a double rounding problem. Let's trace where rounding happens:
First rounding: The protocol converts lp_tokens_to_burn into withdraw_lamports using calc_lamports_for_withdrawal. This rounds down, the user gets slightly fewer lamports than the mathematically exact value.
Second rounding: The fix then converts split_lamports back into LP tokens using calc_lp_tokens_for_deposit. This also rounds down, the burn amount is slightly fewer tokens than the mathematically exact value.
Two sequential floor operations compound in the same direction: the user receives lamports that were already rounded down, and then the tokens burned for those lamports are rounded down again.
The net effect is that fewer tokens are destroyed than the lamports removed actually warrant. Every session withdrawal leaves a small residue of unbacked tokens in circulation, diluting the value for all remaining depositors.
Simulating the Rounding Impact
We built a spreadsheet simulation to understand how this rounding error behaves under different pool conditions.
Could someone profit from this?
In theory, yes, an attacker could short the pool's LP token on a secondary market, then trigger enough withdrawals to measurably drag down the exchange rate.
But getting there is impractical.
SOL has 9 decimal places of precision, so even with only 1,000 SOL in the pool, the attacker would need to trigger the rounding error on the order of 1,000 × 10⁹ times to move the price meaningfully.
The real damage isn't an exploit, it's slow, passive dilution that degrades the exchange rate for every depositor over time.
The damage is purely to the pool itself, fewer tokens get burned than should, unbacked tokens accumulate in circulation, and the exchange rate slowly decreases for every depositor.
When the pool is balanced, say total_lamports = 10M and lp_token_supply = 10M, the rounding error per withdrawal is at most 1 lamport.
But if the ratio from the beginning is imbalanced, the error per withdrawal grows.
In general, the error is bounded by lp_token_supply / total_lamports, the wider the ratio, the larger the per-withdrawal error.
And the problem feeds itself: every withdrawal that under-burns tokens makes the imbalance worse.
More unbacked tokens stay in circulation, the token price drops, the ratio gets wider, and the next withdrawal under-burns by even more.
The Fix That Removed the Double Rounding Error
Instead of reverse-converting split_lamports back into LP tokens, the team changed who funds the PDA rent.
The stake account rent is now paid from the pool's reserve stake account, not from the user's withdrawn lamports.
This means withdraw_lamports and split_lamports are the same value.
The original lp_tokens_to_burn stays untouched, no second conversion, no second rounding.
Conclusion
The rounding mismatch only becomes visible when you trace the full arithmetic path across both conversions and check whether the floor operations compound.
That's the pattern to internalize: any time a value is converted in one direction and then reverse-converted, two sequential floors will always favor the same side.
Manual review alone won't reliably catch this.
Two techniques would have surfaced it:
- Fuzzing, define the invariant tokens_burned × total_lamports >= lamports_withdrawn × token_supply and test it. Any violation means the pool is under-burning.
- Numerical simulation, we built a spreadsheet that ran the withdrawal loop across a range of pool ratios and tracked cumulative drift between expected and actual token supply.
Every conversion function has an implicit rounding direction, and every rounding direction has a beneficiary.
When reviewing a fix to financial logic, don't just ask "does this fix the reported issue?"
Ask: "did this fix preserve the rounding invariants in the pool?"
If you’re building or auditing staking systems or any mechanism with bidirectional conversions, review your rounding boundaries carefully.
For deeper invariant testing and audit support, reach out to Adevar Labs.
Ship safely.



