Skip to content

Commit 7a18064

Browse files
authored
program: move to lamport-based accounting (#601)
1 parent f0342e3 commit 7a18064

7 files changed

Lines changed: 518 additions & 136 deletions

File tree

program/src/error.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ pub enum SinglePoolError {
5454

5555
// 10
5656
/// Not enough stake to cover the provided quantity of pool tokens.
57-
/// (Generally this should not happen absent user error, but may if the
58-
/// minimum delegation increases beyond 1 sol.)
57+
/// This typically means the value exists in the pool as activating stake,
58+
/// and an epoch is required for it to become available. Otherwise, it means
59+
/// active stake in the on-ramp must be moved via `ReplenishPool`.
5960
#[error("WithdrawalTooLarge")]
6061
WithdrawalTooLarge,
6162
/// Required signature is missing.
@@ -105,6 +106,10 @@ pub enum SinglePoolError {
105106
/// is in an exceptional state, or because the on-ramp account should be refreshed.
106107
#[error("ReplenishRequired")]
107108
ReplenishRequired,
109+
/// Withdrawal would render the pool stake account impossible to redelegate.
110+
/// This can only occur if the Stake Program minimum delegation increases above 1 sol.
111+
#[error("WithdrawalViolatesPoolRequirements")]
112+
WithdrawalViolatesPoolRequirements,
108113
}
109114
impl From<SinglePoolError> for ProgramError {
110115
fn from(e: SinglePoolError) -> Self {
@@ -137,8 +142,9 @@ impl ToStr for SinglePoolError {
137142
"Error: Not enough pool tokens provided to withdraw stake worth one lamport.",
138143
SinglePoolError::WithdrawalTooLarge =>
139144
"Error: Not enough stake to cover the provided quantity of pool tokens. \
140-
(Generally this should not happen absent user error, but may if the minimum delegation increases \
141-
beyond 1 sol.)",
145+
This typically means the value exists in the pool as activating stake, \
146+
and an epoch is required for it to become available. Otherwise, it means \
147+
active stake in the onramp must be moved via `ReplenishPool`.",
142148
SinglePoolError::SignatureMissing => "Error: Required signature is missing.",
143149
SinglePoolError::WrongStakeState => "Error: Stake account is not in the state expected by the program.",
144150
SinglePoolError::ArithmeticOverflow => "Error: Unsigned subtraction crossed the zero.",
@@ -157,11 +163,14 @@ impl ToStr for SinglePoolError {
157163
SinglePoolError::InvalidPoolOnRampAccount =>
158164
"Error: Provided pool onramp account does not match address derived from the pool account.",
159165
SinglePoolError::OnRampDoesntExist =>
160-
"The onramp account for this pool does not exist; you must call `InitializePoolOnRamp` \
166+
"Error: The onramp account for this pool does not exist; you must call `InitializePoolOnRamp` \
161167
before you can perform this operation.",
162168
SinglePoolError::ReplenishRequired =>
163169
"Error: The present operation requires a `ReplenishPool` call, either because the pool stake account \
164170
is in an exceptional state, or because the on-ramp account should be refreshed.",
171+
SinglePoolError::WithdrawalViolatesPoolRequirements =>
172+
"Error: Withdrawal would render the pool stake account impossible to redelegate. \
173+
This can only occur if the Stake Program minimum delegation increases above 1 sol.",
165174
}
166175
}
167176
}

program/src/processor.rs

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -40,33 +40,49 @@ use {
4040
spl_token_interface::{self as spl_token, state::Mint},
4141
};
4242

43-
/// Calculate pool tokens to mint, given outstanding token supply, pool active
44-
/// stake, and deposit active stake
43+
/// Determine the canonical value of the pool from its staked and stake-able lamports
44+
fn pool_net_asset_value(
45+
pool_stake_info: &AccountInfo,
46+
pool_onramp_info: &AccountInfo,
47+
rent: &Rent,
48+
) -> u64 {
49+
// these numbers should typically be equal, but might differ during StakeState upgrades
50+
let pool_rent_exempt_reserve = rent.minimum_balance(pool_stake_info.data_len());
51+
let onramp_rent_exempt_reserve = rent.minimum_balance(pool_onramp_info.data_len());
52+
53+
// NEV is all lamports in both accounts less rent
54+
pool_stake_info
55+
.lamports()
56+
.saturating_add(pool_onramp_info.lamports())
57+
.saturating_sub(pool_rent_exempt_reserve)
58+
.saturating_sub(onramp_rent_exempt_reserve)
59+
}
60+
61+
/// Calculate pool tokens to mint, given outstanding token supply, pool NEV, and deposit amount
4562
fn calculate_deposit_amount(
4663
pre_token_supply: u64,
47-
pre_pool_stake: u64,
48-
user_stake_to_deposit: u64,
64+
pre_pool_nev: u64,
65+
user_deposit_amount: u64,
4966
) -> Option<u64> {
50-
if pre_pool_stake == 0 || pre_token_supply == 0 {
51-
Some(user_stake_to_deposit)
67+
if pre_pool_nev == 0 || pre_token_supply == 0 {
68+
Some(user_deposit_amount)
5269
} else {
5370
u64::try_from(
54-
(user_stake_to_deposit as u128)
71+
(user_deposit_amount as u128)
5572
.checked_mul(pre_token_supply as u128)?
56-
.checked_div(pre_pool_stake as u128)?,
73+
.checked_div(pre_pool_nev as u128)?,
5774
)
5875
.ok()
5976
}
6077
}
6178

62-
/// Calculate pool stake to return, given outstanding token supply, pool active
63-
/// stake, and tokens to redeem
79+
/// Calculate pool value to return, given outstanding token supply, pool NEV, and tokens to redeem
6480
fn calculate_withdraw_amount(
6581
pre_token_supply: u64,
66-
pre_pool_stake: u64,
82+
pre_pool_nev: u64,
6783
user_tokens_to_burn: u64,
6884
) -> Option<u64> {
69-
let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_stake as u128)?;
85+
let numerator = (user_tokens_to_burn as u128).checked_mul(pre_pool_nev as u128)?;
7086
let denominator = pre_token_supply as u128;
7187
if numerator < denominator || denominator == 0 {
7288
Some(0)
@@ -774,8 +790,8 @@ impl Processor {
774790
let stake_config_info = next_account_info(account_info_iter)?;
775791
let stake_program_info = next_account_info(account_info_iter)?;
776792

793+
let rent = Rent::get()?;
777794
let stake_history = &StakeHistorySysvar(clock.epoch);
778-
let minimum_delegation = stake::tools::get_minimum_delegation()?;
779795

780796
check_vote_account(vote_account_info)?;
781797
check_pool_address(program_id, vote_account_info.key, pool_info.key)?;
@@ -791,8 +807,9 @@ impl Processor {
791807
)?;
792808
check_stake_program(stake_program_info.key)?;
793809

810+
let minimum_delegation = stake::tools::get_minimum_delegation()?;
811+
794812
// we expect these numbers to be equal but get them separately in case of future changes
795-
let rent = Rent::get()?;
796813
let pool_rent_exempt_reserve = rent.minimum_balance(pool_stake_info.data_len());
797814
let onramp_rent_exempt_reserve = rent.minimum_balance(pool_onramp_info.data_len());
798815

@@ -970,6 +987,7 @@ impl Processor {
970987
let token_program_info = next_account_info(account_info_iter)?;
971988
let stake_program_info = next_account_info(account_info_iter)?;
972989

990+
let rent = &Rent::get()?;
973991
let stake_history = &StakeHistorySysvar(clock.epoch);
974992

975993
SinglePool::from_account_info(pool_info, program_id)?;
@@ -998,9 +1016,8 @@ impl Processor {
9981016
return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into());
9991017
}
10001018

1001-
let (_, pool_stake_state) = get_stake_state(pool_stake_info)?;
1002-
1003-
let (pool_is_active, pool_is_activating) = {
1019+
let (pre_pool_stake, pool_is_active, pool_is_activating) = {
1020+
let (_, pool_stake_state) = get_stake_state(pool_stake_info)?;
10041021
let pool_stake_status = pool_stake_state
10051022
.delegation
10061023
.stake_activating_and_deactivating(
@@ -1010,6 +1027,7 @@ impl Processor {
10101027
);
10111028

10121029
(
1030+
pool_stake_state.delegation.stake,
10131031
is_stake_fully_active(&pool_stake_status),
10141032
is_stake_newly_activating(&pool_stake_status),
10151033
)
@@ -1030,10 +1048,10 @@ impl Processor {
10301048
unreachable!();
10311049
};
10321050

1033-
let pre_pool_stake = pool_stake_state.delegation.stake;
1034-
let pre_pool_lamports = pool_stake_info.lamports();
1035-
msg!("Available stake pre merge {}", pre_pool_stake);
1051+
// tokens for deposit are determined off the total stakeable value of both pool-owned accounts
1052+
let pre_total_nev = pool_net_asset_value(pool_stake_info, pool_onramp_info, rent);
10361053

1054+
let pre_user_lamports = user_stake_info.lamports();
10371055
let (user_stake_meta, user_stake_status) = match deserialize_stake(user_stake_info) {
10381056
Ok(StakeStateV2::Stake(meta, stake, _)) => (
10391057
meta,
@@ -1078,21 +1096,15 @@ impl Processor {
10781096
stake_history_info.clone(),
10791097
)?;
10801098

1081-
let (_, pool_stake_state) = get_stake_state(pool_stake_info)?;
1082-
let post_pool_stake = pool_stake_state.delegation.stake;
1083-
let post_pool_lamports = pool_stake_info.lamports();
1084-
msg!("Available stake post merge {}", post_pool_stake);
1085-
1086-
// stake lamports added, as a stake difference
1087-
let stake_added = post_pool_stake
1099+
// determine new stake lamports added by merge
1100+
let post_pool_stake = get_stake_amount(pool_stake_info)?;
1101+
let new_stake_added = post_pool_stake
10881102
.checked_sub(pre_pool_stake)
10891103
.ok_or(SinglePoolError::ArithmeticOverflow)?;
10901104

1091-
// if there were excess lamports in the user-provided account, we return them
1092-
// this includes their rent-exempt reserve if the pool is fully active
1093-
let user_excess_lamports = post_pool_lamports
1094-
.checked_sub(pre_pool_lamports)
1095-
.and_then(|amount| amount.checked_sub(stake_added))
1105+
// return user lamports that were not added to stake
1106+
let user_excess_lamports = pre_user_lamports
1107+
.checked_sub(new_stake_added)
10961108
.ok_or(SinglePoolError::ArithmeticOverflow)?;
10971109

10981110
// sanity check: the user stake account is empty
@@ -1101,8 +1113,9 @@ impl Processor {
11011113
}
11021114

11031115
// deposit amount is determined off stake added because we return excess lamports
1104-
let new_pool_tokens = calculate_deposit_amount(token_supply, pre_pool_stake, stake_added)
1105-
.ok_or(SinglePoolError::UnexpectedMathError)?;
1116+
let new_pool_tokens =
1117+
calculate_deposit_amount(token_supply, pre_total_nev, new_stake_added)
1118+
.ok_or(SinglePoolError::UnexpectedMathError)?;
11061119

11071120
if new_pool_tokens == 0 {
11081121
return Err(SinglePoolError::DepositTooSmall.into());
@@ -1152,9 +1165,13 @@ impl Processor {
11521165
let user_stake_info = next_account_info(account_info_iter)?;
11531166
let user_token_account_info = next_account_info(account_info_iter)?;
11541167
let clock_info = next_account_info(account_info_iter)?;
1168+
let clock = &Clock::from_account_info(clock_info)?;
11551169
let token_program_info = next_account_info(account_info_iter)?;
11561170
let stake_program_info = next_account_info(account_info_iter)?;
11571171

1172+
let rent = &Rent::get()?;
1173+
let stake_history = &StakeHistorySysvar(clock.epoch);
1174+
11581175
SinglePool::from_account_info(pool_info, program_id)?;
11591176

11601177
check_pool_stake_address(program_id, pool_info.key, pool_stake_info.key)?;
@@ -1181,26 +1198,73 @@ impl Processor {
11811198
return Err(SinglePoolError::InvalidPoolStakeAccountUsage.into());
11821199
}
11831200

1184-
// we deliberately do NOT validate the activation status of the pool account.
1185-
// neither snow nor rain nor warmup/cooldown nor validator delinquency prevents a user withdrawal
1186-
//
1187-
// NOTE this is fine for stake v4 but subtly wrong for stake v5 *if* the pool account was deactivated.
1188-
// stake v5 declines to (meaninglessly) adjust delegations of deactivated sources.
1189-
// this will (again) be correct with #581, which shifts to NEV accounting on lamports rather than stake.
1190-
// we should plan another SVSP release before stake v5 activation
1191-
let pre_pool_stake = get_stake_amount(pool_stake_info)?;
1192-
msg!("Available stake pre split {}", pre_pool_stake);
1193-
1194-
// withdraw amount is determined off stake just like deposit amount
1195-
let withdraw_stake = calculate_withdraw_amount(token_supply, pre_pool_stake, token_amount)
1196-
.ok_or(SinglePoolError::UnexpectedMathError)?;
1197-
1198-
if withdraw_stake == 0 {
1201+
if token_amount == 0 {
11991202
return Err(SinglePoolError::WithdrawalTooSmall.into());
12001203
}
12011204

1202-
// the second case should never be true, but its best to be sure
1203-
if withdraw_stake > pre_pool_stake || withdraw_stake == pool_stake_info.lamports() {
1205+
let minimum_delegation = stake::tools::get_minimum_delegation()?;
1206+
1207+
// tokens for withdraw are determined off the total stakeable value of both pool-owned accounts
1208+
let pre_total_nev = pool_net_asset_value(pool_stake_info, pool_onramp_info, rent);
1209+
1210+
// note we deliberately do NOT validate the activation status of the pool account.
1211+
// neither warmup/cooldown nor validator delinquency prevent a user withdrawal.
1212+
// however, because we calculate NEV from all lamports in both pool accounts,
1213+
// but can only split stake from the main account (unless inactive), we must determine whether this is possible
1214+
let (withdrawable_value, pool_is_fully_inactive) = {
1215+
let (_, pool_stake_state) = get_stake_state(pool_stake_info)?;
1216+
let pool_stake_status = pool_stake_state
1217+
.delegation
1218+
.stake_activating_and_deactivating(
1219+
clock.epoch,
1220+
stake_history,
1221+
PERPETUAL_NEW_WARMUP_COOLDOWN_RATE_EPOCH,
1222+
);
1223+
1224+
// if fully inactive, we split on lamports; otherwise, on all delegation.
1225+
// the stake program works off delegation in this way *even* for a partially deactivated stake
1226+
if pool_stake_status == StakeActivationStatus::default() {
1227+
(
1228+
pool_stake_info
1229+
.lamports()
1230+
.saturating_sub(rent.minimum_balance(pool_stake_info.data_len())),
1231+
true,
1232+
)
1233+
} else {
1234+
(pool_stake_state.delegation.stake, false)
1235+
}
1236+
};
1237+
1238+
// withdraw amount is determined off pool NEV just like deposit amount
1239+
let stake_to_withdraw =
1240+
calculate_withdraw_amount(token_supply, pre_total_nev, token_amount)
1241+
.ok_or(SinglePoolError::UnexpectedMathError)?;
1242+
1243+
// self-explanatory. we catch 0 deposit above so we only hit this if we rounded to 0
1244+
if stake_to_withdraw == 0 {
1245+
return Err(SinglePoolError::WithdrawalTooSmall.into());
1246+
}
1247+
1248+
// the pool must *always* meet minimum delegation, even if it is inactive.
1249+
// this error is currently impossible to hit and exists to protect pools if minimum delegation rises above 1sol
1250+
if withdrawable_value.saturating_sub(stake_to_withdraw) < minimum_delegation {
1251+
return Err(SinglePoolError::WithdrawalViolatesPoolRequirements.into());
1252+
}
1253+
1254+
// this is impossible but we guard explicitly because it would put the pool in an unrecoverable state
1255+
if stake_to_withdraw == pool_stake_info.lamports() {
1256+
return Err(SinglePoolError::WithdrawalViolatesPoolRequirements.into());
1257+
}
1258+
1259+
// if the destination would be in any non-inactive state it must meet minimum delegation
1260+
if !pool_is_fully_inactive && stake_to_withdraw < minimum_delegation {
1261+
return Err(SinglePoolError::WithdrawalTooSmall.into());
1262+
}
1263+
1264+
// if we do not have enough value to service this withdrawal, the user must wait a `ReplenishPool` cycle.
1265+
// this does *not* mean the value isnt in the pool, merely that it is not duly splittable.
1266+
// this check should always come last to avoid returning it if the withdrawal is actually invalid
1267+
if stake_to_withdraw > withdrawable_value {
12041268
return Err(SinglePoolError::WithdrawalTooLarge.into());
12051269
}
12061270

@@ -1221,7 +1285,7 @@ impl Processor {
12211285
pool_stake_info.clone(),
12221286
pool_stake_authority_info.clone(),
12231287
stake_authority_bump_seed,
1224-
withdraw_stake,
1288+
stake_to_withdraw,
12251289
user_stake_info.clone(),
12261290
)?;
12271291

@@ -1235,9 +1299,6 @@ impl Processor {
12351299
clock_info.clone(),
12361300
)?;
12371301

1238-
let post_pool_stake = get_stake_amount(pool_stake_info)?;
1239-
msg!("Available stake post split {}", post_pool_stake);
1240-
12411302
Ok(())
12421303
}
12431304

@@ -1547,7 +1608,7 @@ mod tests {
15471608
test_case::test_case,
15481609
};
15491610

1550-
// approximately 6%/yr assuking 146 epochs
1611+
// approximately 6%/yr assuming 146 epochs
15511612
const INFLATION_BASE_RATE: f64 = 0.0004;
15521613

15531614
#[derive(Clone, Debug, Default)]

0 commit comments

Comments
 (0)