From 9e76b2630ae3b491c01999d782f87cf0ee21b9be Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 7 Mar 2025 10:54:45 +0100 Subject: [PATCH 01/19] Removing libssl1.1 dependency as it can no longer be installed on CI (#199) --- ci/install-build-deps.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/ci/install-build-deps.sh b/ci/install-build-deps.sh index cd9c815df08..3962591173a 100755 --- a/ci/install-build-deps.sh +++ b/ci/install-build-deps.sh @@ -7,7 +7,6 @@ sudo apt-add-repository "deb http://apt.llvm.org/bionic/ llvm-toolchain-bionic-1 sudo apt-get update sudo apt-get install -y openssl --allow-unauthenticated sudo apt-get install -y libssl-dev --allow-unauthenticated -sudo apt-get install -y libssl1.1 --allow-unauthenticated sudo apt-get install -y libudev-dev sudo apt-get install -y binutils-dev sudo apt-get install -y libunwind-dev From 5fc80c2ae7204c197778143dce8c8d427e1c70e4 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 14 Mar 2025 13:02:31 +0100 Subject: [PATCH 02/19] [Liquidity Mining] Creating data structures (1) (#197) --- .../sdk/src/state/liquidity_mining.rs | 147 ++++++++++++++++++ token-lending/sdk/src/state/obligation.rs | 26 +++- token-lending/sdk/src/state/reserve.rs | 15 +- 3 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 token-lending/sdk/src/state/liquidity_mining.rs diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs new file mode 100644 index 00000000000..c4a73849ebc --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -0,0 +1,147 @@ +use crate::math::Decimal; +use solana_program::pubkey::Pubkey; + +/// Determines the size of [PoolRewardManager] +/// TODO: This should be configured when we're dealing with migrations later but we should aim for 50. +const MAX_REWARDS: usize = 44; + +/// Each reserve has two managers: +/// - one for deposits +/// - one for borrows +pub struct PoolRewardManager { + /// Is updated when we change user shares in the reserve. + pub total_shares: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// New [PoolReward] are added to the first vacant slot. + pub pool_rewards: [PoolRewardSlot; MAX_REWARDS], +} + +/// Each pool reward gets an ID which is monotonically increasing with each +/// new reward added to the pool at the particular slot. +/// +/// This helps us distinguish between two distinct rewards in the same array +/// index across time. +/// +/// # Wrapping +/// There are two strategies to handle wrapping: +/// 1. Consider the associated slot locked forever +/// 2. Go back to 0. +/// +/// Given that one reward lasts at least 1 hour we've got at least half a +/// million years before we need to worry about wrapping in a single slot. +/// I'd call that someone else's problem. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PoolRewardId(pub u32); + +/// # (Un)Packing +/// This is unpacked representation. +/// When packing we use the [PoolReward] `reward_mint` to determine whether the +/// reward is vacant or not to save space. +/// +/// If the pubkey is eq to default pubkey then slot is vacant. +pub enum PoolRewardSlot { + /// New reward can be added to this slot. + Vacant { + /// Increment this ID when adding new [PoolReward]. + last_pool_reward_id: PoolRewardId, + }, + /// Reward has not been closed yet. + Occupied(PoolReward), +} + +/// Tracks rewards in a specific mint over some period of time. +pub struct PoolReward { + /// Unique ID for this slot that has never been used before, and will never + /// be used again. + pub id: PoolRewardId, + /// # (Un)Packing + /// When we pack the reward we set this to default pubkey for vacant slots. + pub vault: Pubkey, + /// Monotonically increasing time taken from clock sysvar. + pub start_time_secs: u64, + /// For how long (since start time) will this reward be releasing tokens. + pub duration_secs: u32, + /// Total token amount to distribute. + /// The token account that holds the rewards holds at least this much in + /// the beginning. + pub total_rewards: u64, + /// How many users are still tracking this reward. + /// Once this reaches zero we can close this reward. + /// There's a permission-less ix with which user rewards can be distributed + /// that's used for cranking remaining rewards. + pub num_user_reward_managers: u64, + /// Amount of rewards that have been made available to users. + /// + /// We keep adding `(total_rewards * time_passed) / (total_time)` every + /// time someone interacts with the manager + /// ([update_pool_reward_manager]). + pub allocated_rewards: Decimal, + /// We keep adding `(unlocked_rewards) / (total_shares)` every time + /// someone interacts with the manager ([update_pool_reward_manager]) + /// where + /// `unlocked_rewards = (total_rewards * time_passed) / (total_time)` + pub cumulative_rewards_per_share: Decimal, +} + +/// Tracks user's LM rewards for a specific pool (reserve.) +pub struct UserRewardManager { + /// User cannot both borrow and deposit in the same reserve. + /// This manager is unique for this reserve within the [Obligation]. + /// + /// We know whether to use [crate::state::Reserve]'s + /// `deposits_pool_reward_manager` or `borrows_pool_reward_manager` based on + /// this field. + /// + /// One optimization we could make is to link the [UserRewardManager] via + /// index which would save 32 bytes per [UserRewardManager]. + /// However, that does make the program logic more error prone. + pub reserve: Pubkey, + /// For deposits, this is the amount of collateral token user has in + /// their obligation deposit. + /// + /// For borrows, this is (borrow_amount / cumulative_borrow_rate) user + /// has in their obligation borrow. + pub share: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// The index of each reward is important. + /// It will match the index in the [PoolRewardManager] of the reserve. + pub rewards: Vec>, +} + +/// Track user rewards for a specific [PoolReward]. +pub struct UserReward { + /// Each pool reward gets an ID which is monotonically increasing with each + /// new reward added to the pool. + pub pool_reward_id: PoolRewardId, + /// Before [UserReward.cumulative_rewards_per_share] is copied we find + /// time difference between current global rewards and last user update + /// rewards: + /// [PoolReward.cumulative_rewards_per_share] - [UserReward.cumulative_rewards_per_share] + /// + /// Then, we multiply that difference by [UserRewardManager.share] and + /// add the result to this counter. + pub earned_rewards: Decimal, + /// copied from [PoolReward.cumulative_rewards_per_share] at the time of the last update + pub cumulative_rewards_per_share: Decimal, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_fits_reserve_realloc_into_single_ix() { + const MAX_REALLOC: usize = 10 * 1024; + + let size_of_discriminant = 1; + let const_size_of_pool_manager = 8 + 8; + let required_realloc = size_of_discriminant + + const_size_of_pool_manager + + 2 * MAX_REWARDS * std::mem::size_of::(); + + println!("assert {required_realloc} <= {MAX_REALLOC}"); + assert!(required_realloc <= MAX_REALLOC); + } +} diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 6f9f43ef18e..e17bcfa2f21 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -65,6 +65,15 @@ pub struct Obligation { pub closeable: bool, } +/// These are the two foundational user interactions in a borrow-lending protocol. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PositionKind { + /// User is providing liquidity. + Deposit = 0, + /// User is owing liquidity. + Borrow = 1, +} + impl Obligation { /// Create a new obligation pub fn new(params: InitObligationParams) -> Self { @@ -414,13 +423,15 @@ impl ObligationLiquidity { const OBLIGATION_COLLATERAL_LEN: usize = 88; // 32 + 8 + 16 + 32 const OBLIGATION_LIQUIDITY_LEN: usize = 112; // 32 + 16 + 16 + 16 + 32 -const OBLIGATION_LEN: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) - // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca +/// This is the size of the account _before_ LM feature was added. +const OBLIGATION_LEN_V1: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) + // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca impl Pack for Obligation { - const LEN: usize = OBLIGATION_LEN; + const LEN: usize = OBLIGATION_LEN_V1; + // @v2.1.0 TODO: pack vec of user reward managers fn pack_into_slice(&self, dst: &mut [u8]) { - let output = array_mut_ref![dst, 0, OBLIGATION_LEN]; + let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( version, @@ -527,9 +538,10 @@ impl Pack for Obligation { } } - /// Unpacks a byte buffer into an [ObligationInfo](struct.ObligationInfo.html). + /// Unpacks a byte buffer into an [Obligation]. + // @v2.1.0 TODO: unpack vector of optional user reward managers fn unpack_from_slice(src: &[u8]) -> Result { - let input = array_ref![src, 0, OBLIGATION_LEN]; + let input = array_ref![src, 0, OBLIGATION_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( version, @@ -693,7 +705,7 @@ mod test { closeable: rng.gen(), }; - let mut packed = [0u8; OBLIGATION_LEN]; + let mut packed = [0u8; OBLIGATION_LEN_V1]; Obligation::pack(obligation.clone(), &mut packed).unwrap(); let unpacked = Obligation::unpack(&packed).unwrap(); assert_eq!(obligation, unpacked); diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index 14092c277e6..2e53ba3e19d 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -1228,13 +1228,15 @@ impl IsInitialized for Reserve { } } -const RESERVE_LEN: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 +/// This is the size of the account _before_ LM feature was added. +const RESERVE_LEN_V1: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 impl Pack for Reserve { - const LEN: usize = RESERVE_LEN; + const LEN: usize = RESERVE_LEN_V1; // @TODO: break this up by reserve / liquidity / collateral / config https://git.io/JOCca + // @v2.1.0 TODO: pack deposits_pool_reward_manager and borrows_pool_reward_manager fn pack_into_slice(&self, output: &mut [u8]) { - let output = array_mut_ref![output, 0, RESERVE_LEN]; + let output = array_mut_ref![output, 0, RESERVE_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( version, @@ -1422,9 +1424,12 @@ impl Pack for Reserve { pack_decimal(self.attributed_borrow_value, attributed_borrow_value); } - /// Unpacks a byte buffer into a [ReserveInfo](struct.ReserveInfo.html). + /// Unpacks a byte buffer into a [Reserve]. + // @v2.1.0 TODO: unpack deposits_pool_reward_manager and borrows_pool_reward_manager + // but default them if they are not present, this is part of the + // migration process fn unpack_from_slice(input: &[u8]) -> Result { - let input = array_ref![input, 0, RESERVE_LEN]; + let input = array_ref![input, 0, RESERVE_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( version, From beb136f89aab6be725c6b85dd8af980ab6a8836d Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 14 Mar 2025 13:04:44 +0100 Subject: [PATCH 03/19] [Liquidity Mining] Adding admin ixs for reward management (2) (#198) --- token-lending/program/src/processor.rs | 75 ++ .../program/src/processor/liquidity_mining.rs | 714 ++++++++++++++++++ token-lending/sdk/src/error.rs | 8 + token-lending/sdk/src/instruction.rs | 152 +++- token-lending/sdk/src/state/mod.rs | 2 + token-lending/sdk/src/state/obligation.rs | 12 + 6 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 token-lending/program/src/processor/liquidity_mining.rs diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index b92aea55911..470558d4f38 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -1,5 +1,7 @@ //! Program state processor +mod liquidity_mining; + use crate::state::Bonus; use crate::{ self as solend_program, @@ -202,6 +204,46 @@ pub fn process_instruction( msg!("Instruction: Donate To Reserve"); process_donate_to_reserve(program_id, liquidity_amount, accounts) } + LendingInstruction::AddPoolReward { + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } => { + msg!("Instruction: Add Pool Reward"); + liquidity_mining::process_add_pool_reward( + program_id, + position_kind, + start_time_secs, + end_time_secs, + token_amount, + accounts, + ) + } + LendingInstruction::CancelPoolReward { + position_kind, + pool_reward_index, + } => { + msg!("Instruction: Cancel Pool Reward"); + liquidity_mining::process_cancel_pool_reward( + program_id, + position_kind, + pool_reward_index, + accounts, + ) + } + LendingInstruction::ClosePoolReward { + position_kind, + pool_reward_index, + } => { + msg!("Instruction: Close Pool Reward"); + liquidity_mining::process_close_pool_reward( + program_id, + position_kind, + pool_reward_index, + accounts, + ) + } } } @@ -3436,6 +3478,31 @@ fn spl_token_burn(params: TokenBurnParams<'_, '_>) -> ProgramResult { result.map_err(|_| LendingError::TokenBurnFailed.into()) } +/// Issue a spl_token `CloseAccount` instruction. +#[inline(always)] +fn spl_token_close_account(params: TokenCloseAccountParams<'_, '_>) -> ProgramResult { + let TokenCloseAccountParams { + account, + destination, + authority, + token_program, + authority_signer_seeds, + } = params; + let result = invoke_optionally_signed( + &spl_token::instruction::close_account( + token_program.key, + account.key, + destination.key, + authority.key, + &[], + )?, + &[account, destination, authority, token_program], + authority_signer_seeds, + ); + + result.map_err(|_| LendingError::CloseTokenAccountFailed.into()) +} + fn is_cpi_call( program_id: &Pubkey, current_index: usize, @@ -3507,3 +3574,11 @@ struct TokenBurnParams<'a: 'b, 'b> { authority_signer_seeds: &'b [&'b [u8]], token_program: AccountInfo<'a>, } + +struct TokenCloseAccountParams<'a: 'b, 'b> { + account: AccountInfo<'a>, + destination: AccountInfo<'a>, + authority: AccountInfo<'a>, + authority_signer_seeds: &'b [&'b [u8]], + token_program: AccountInfo<'a>, +} diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs new file mode 100644 index 00000000000..76defa6c5e6 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -0,0 +1,714 @@ +//! Liquidity mining is a feature where depositors and borrowers are rewarded +//! for using the protocol. +//! The rewards are in the form of tokens that a lending market owner can attach +//! to each reserve. +//! +//! The feature is built with reference to the [Suilend][suilend-lm] +//! implementation of the same feature. +//! +//! There are three admin-only ixs: +//! - [add_pool_reward] +//! - [cancel_pool_reward] +//! - [close_pool_reward] +//! +//! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 + +use crate::processor::{ + assert_rent_exempt, spl_token_close_account, spl_token_init_account, spl_token_transfer, + TokenCloseAccountParams, TokenInitializeAccountParams, TokenTransferParams, +}; +use add_pool_reward::{AddPoolRewardAccounts, AddPoolRewardParams}; +use cancel_pool_reward::{CancelPoolRewardAccounts, CancelPoolRewardParams}; +use close_pool_reward::{ClosePoolRewardAccounts, ClosePoolRewardParams}; +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + sysvar::Sysvar, +}; +use solend_sdk::{ + error::LendingError, + state::{LendingMarket, PositionKind, Reserve}, +}; +use spl_token::state::Account as TokenAccount; +use std::convert::TryInto; + +/// Cannot create a reward shorter than this. +const MIN_REWARD_PERIOD_SECS: u64 = 3_600; + +/// # Accounts +/// +/// See [add_pool_reward::AddPoolRewardAccounts::from_unchecked_iter] for a list +/// of accounts and their constraints. +/// +/// # Effects +/// +/// 1. Initializes a new reward vault account and transfers +/// `reward_token_amount` tokens from the `reward_token_source` account to +/// the new reward vault account. +/// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. +/// 3. Packs all changes into account buffers. +pub(crate) fn process_add_pool_reward( + program_id: &Pubkey, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + let params = AddPoolRewardParams::new( + position_kind, + start_time_secs, + end_time_secs, + reward_token_amount, + )?; + + let accounts = + AddPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; + + // 1. + + spl_token_init_account(TokenInitializeAccountParams { + account: accounts.reward_token_vault_info.clone(), + mint: accounts.reward_mint_info.clone(), + owner: accounts.reward_authority_info.clone(), + rent: accounts.rent_info.clone(), + token_program: accounts.token_program_info.clone(), + })?; + let rent = &Rent::from_account_info(accounts.rent_info)?; + assert_rent_exempt(rent, accounts.reward_token_vault_info)?; + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_source_info.clone(), + destination: accounts.reward_token_vault_info.clone(), + amount: params.reward_token_amount, + authority: accounts.lending_market_owner_info.clone(), + authority_signer_seeds: &[], + token_program: accounts.token_program_info.clone(), + })?; + + // 2. + + todo!("accounts.reserve.add_pool_reward(..)"); + + // 3. + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +/// # Accounts +/// +/// See [cancel_pool_reward::CancelPoolRewardAccounts::from_unchecked_iter] for a list +/// of accounts and their constraints. +/// +/// # Effects +/// +/// 1. Cancels any further reward emission, effectively setting end time to now. +/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. +/// 3. Packs all changes into account buffers. +pub(crate) fn process_cancel_pool_reward( + program_id: &Pubkey, + position_kind: PositionKind, + pool_reward_index: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + let params = CancelPoolRewardParams::new(position_kind, pool_reward_index); + + let accounts = + CancelPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; + + // 1. + + let unallocated_rewards = todo!("accounts.reserve.cancel_pool_reward(..)"); + + // 2. + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.reward_token_destination_info.clone(), + amount: unallocated_rewards, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 3. + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +/// # Accounts +/// +/// See [close_pool_reward::ClosePoolRewardAccounts::from_unchecked_iter] for a list +/// of accounts and their constraints. +/// +/// # Effects +/// +/// 1. Closes reward in the [Reserve] account if all users have claimed. +/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. +/// 3. Closes reward vault token account. +/// 3. Packs all changes into account buffers. +pub(crate) fn process_close_pool_reward( + program_id: &Pubkey, + position_kind: PositionKind, + pool_reward_index: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + let params = ClosePoolRewardParams::new(position_kind, pool_reward_index); + + let accounts = + ClosePoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; + + // 1. + + let unallocated_rewards = todo!("accounts.reserve.close_pool_reward(..)"); + + // 2. + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.reward_token_destination_info.clone(), + amount: unallocated_rewards, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 3. + + spl_token_close_account(TokenCloseAccountParams { + account: accounts.reward_token_vault_info.clone(), + destination: accounts.lending_market_owner_info.clone(), + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 4. + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +/// Unpacks a spl_token [TokenAccount]. +fn unpack_token_account(data: &[u8]) -> Result { + TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount) +} + +/// Derives the reward vault authority PDA address. +fn reward_vault_authority( + program_id: &Pubkey, + lending_market_key: &Pubkey, + reserve_key: &Pubkey, + reward_mint_key: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key), + program_id, + ) +} + +fn reward_vault_authority_seeds<'keys>( + lending_market_key: &'keys Pubkey, + reserve_key: &'keys Pubkey, + reward_mint_key: &'keys Pubkey, +) -> [&'keys [u8]; 4] { + [ + b"RewardVaultAuthority", + lending_market_key.as_ref(), + reserve_key.as_ref(), + reward_mint_key.as_ref(), + ] +} + +mod add_pool_reward { + use super::*; + + /// Use [Self::new] to validate the parameters. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) struct AddPoolRewardParams { + pub(super) position_kind: PositionKind, + /// At least the current timestamp. + pub(super) start_time_secs: u64, + /// Larger than [MIN_REWARD_PERIOD_SECS]. + pub(super) duration_secs: u32, + /// Larger than zero. + pub(super) reward_token_amount: u64, + + _priv: (), + } + + /// Use [Self::from_unchecked_iter] to validate the accounts except for + /// * `reward_token_vault_info` + /// * `rent_info` + pub(super) struct AddPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + pub(super) reserve_info: &'a AccountInfo<'info>, + pub(super) reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ has enough tokens + /// ✅ matches `reward_mint_info` + pub(super) reward_token_source_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + pub(super) reward_authority_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ has no data + /// ❓ we don't yet know whether it's rent exempt + pub(super) reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + pub(super) lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + /// TBD: do we want to create another signer authority to be able to + /// delegate reward management to a softer multisig? + pub(super) lending_market_owner_info: &'a AccountInfo<'info>, + /// ❓ we don't yet whether this is rent info + pub(super) rent_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + pub(super) token_program_info: &'a AccountInfo<'info>, + + pub(super) reserve: Box, + + _priv: (), + } + + impl AddPoolRewardParams { + pub(super) fn new( + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + ) -> Result { + let clock = &Clock::get()?; + + let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); + + if start_time_secs <= end_time_secs { + msg!("Pool reward must end after it starts"); + return Err(LendingError::MathOverflow.into()); + } + + let duration_secs: u32 = { + // SAFETY: just checked that start time is strictly smaller + let d = end_time_secs - start_time_secs; + d.try_into().map_err(|_| { + msg!("Pool reward duration is too long"); + LendingError::MathOverflow + })? + }; + if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { + msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + if reward_token_amount == 0 { + msg!("Pool reward amount must be greater than zero"); + return Err(LendingError::InvalidAmount.into()); + } + + Ok(Self { + position_kind, + start_time_secs, + duration_secs, + reward_token_amount, + + _priv: (), + }) + } + } + + impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + params: &AddPoolRewardParams, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_source_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let rent_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + if reward_token_source_info.owner != token_program_info.key { + msg!("Reward token source provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_source = + unpack_token_account(&reward_token_source_info.data.borrow())?; + if reward_token_source.owner != *lending_market_owner_info.key { + msg!("Reward token source owner does not match the lending market owner provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_source.amount >= params.reward_token_amount { + msg!("Reward token source is empty"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_source.mint != *reward_mint_info.key { + msg!("Reward token source mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reward_token_vault_info.owner != token_program_info.key { + msg!("Reward token vault provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + if !reward_token_vault_info.data.borrow().is_empty() { + msg!("Reward token vault provided must be empty"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok(Self { + reserve_info, + reward_mint_info, + reward_token_source_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + rent_info, + token_program_info, + + reserve, + + _priv: (), + }) + } + } +} + +mod cancel_pool_reward { + use super::*; + + pub(super) struct CancelPoolRewardParams { + position_kind: PositionKind, + pool_reward_index: u64, + + _priv: (), + } + + /// Use [Self::from_unchecked_iter] to validate the accounts. + pub(super) struct CancelPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + pub(super) reserve_info: &'a AccountInfo<'info>, + pub(super) reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ matches `reward_mint_info` + pub(super) reward_token_destination_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + pub(super) reward_authority_info: &'a AccountInfo<'info>, + /// ✅ matches reward vault pubkey stored in the [Reserve] + pub(super) reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + pub(super) lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + pub(super) lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + pub(super) token_program_info: &'a AccountInfo<'info>, + + pub(super) reserve: Box, + + _priv: (), + } + + impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + params: &CancelPoolRewardParams, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + todo!("Check that reward_token_vault_info matches reward vault pubkey stored in [Reserve]"); + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.owner != *lending_market_owner_info.key { + // TBD: superfluous check? + msg!("Reward token destination owner does not match the lending market owner provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok(Self { + _priv: (), + + reserve_info, + reward_mint_info, + reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + + reserve, + }) + } + } + + impl CancelPoolRewardParams { + pub(super) fn new(position_kind: PositionKind, pool_reward_index: u64) -> Self { + Self { + position_kind, + pool_reward_index, + + _priv: (), + } + } + } +} + +mod close_pool_reward { + use super::*; + + pub(super) struct ClosePoolRewardParams { + position_kind: PositionKind, + pool_reward_index: u64, + + _priv: (), + } + + /// Use [Self::from_unchecked_iter] to validate the accounts. + pub(super) struct ClosePoolRewardAccounts<'a, 'info> { + _priv: (), + + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + pub(super) reserve_info: &'a AccountInfo<'info>, + pub(super) reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ matches `reward_mint_info` + pub(super) reward_token_destination_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + pub(super) reward_authority_info: &'a AccountInfo<'info>, + /// ✅ matches reward vault pubkey stored in the [Reserve] + pub(super) reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + pub(super) lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + pub(super) lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + pub(super) token_program_info: &'a AccountInfo<'info>, + + pub(super) reserve: Box, + } + + impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + params: &ClosePoolRewardParams, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + todo!("Check that reward_token_vault_info matches reward vault pubkey stored in [Reserve]"); + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.owner != *lending_market_owner_info.key { + // TBD: superfluous check? + msg!("Reward token destination owner does not match the lending market owner provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok(Self { + reserve_info, + reward_mint_info, + reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + + reserve, + + _priv: (), + }) + } + } + + impl ClosePoolRewardParams { + pub(super) fn new(position_kind: PositionKind, pool_reward_index: u64) -> Self { + Self { + position_kind, + pool_reward_index, + + _priv: (), + } + } + } +} + +/// Common checks within the admin ixs are: +/// +/// * ✅ `reserve_info` belongs to this program +/// * ✅ `reserve_info` unpacks +/// * ✅ `reserve_info` belongs to `lending_market_info` +/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` +/// * ✅ `lending_market_info` belongs to this program +/// * ✅ `lending_market_info` unpacks +/// * ✅ `lending_market_owner_info` is a signer +/// * ✅ `lending_market_owner_info` matches `lending_market_info` +/// * ✅ `token_program_info` matches `lending_market_info` +/// +/// To avoid unpacking reserve twice we return it. +fn check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve<'info>( + program_id: &Pubkey, + reserve_info: &AccountInfo<'info>, + reward_mint_info: &AccountInfo<'info>, + reward_authority_info: &AccountInfo<'info>, + lending_market_info: &AccountInfo<'info>, + lending_market_owner_info: &AccountInfo<'info>, + token_program_info: &AccountInfo<'info>, +) -> Result, ProgramError> { + if reserve_info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + let reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); + + if lending_market_info.owner != program_id { + msg!("Lending market provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + + if reserve.lending_market != *lending_market_info.key { + msg!("Reserve lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if lending_market.token_program_id != *token_program_info.key { + msg!("Lending market token program does not match the token program provided"); + return Err(LendingError::InvalidTokenProgram.into()); + } + + if lending_market.owner != *lending_market_owner_info.key { + msg!("Lending market owner does not match the lending market owner provided"); + return Err(LendingError::InvalidMarketOwner.into()); + } + if !lending_market_owner_info.is_signer { + msg!("Lending market owner provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + let (expected_reward_vault_authority, _bump_seed) = reward_vault_authority( + program_id, + lending_market_info.key, + reserve_info.key, + reward_mint_info.key, + ); + if expected_reward_vault_authority != *reward_authority_info.key { + msg!("Reward vault authority does not match the expected value"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok(reserve) +} diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index 597521cd91c..e5f6302199c 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -209,6 +209,14 @@ pub enum LendingError { /// Borrow Attribution Limit Not Exceeded #[error("Borrow Attribution Limit Not Exceeded")] BorrowAttributionLimitNotExceeded, + /// Pool rewards have a hard coded minimum length in seconds. + #[error("Pool reward too short")] + PoolRewardPeriodTooShort, + + // 60 + /// Cannot close token account + #[error("Cannot close token account")] + CloseTokenAccountFailed, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 4340458ad0f..de75c22a66e 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -1,6 +1,6 @@ //! Instruction types -use crate::state::{LendingMarketMetadata, ReserveType}; +use crate::state::{LendingMarketMetadata, PositionKind, ReserveType}; use crate::{ error::LendingError, state::{RateLimiterConfig, ReserveConfig, ReserveFees}, @@ -528,6 +528,91 @@ pub enum LendingInstruction { /// amount to donate liquidity_amount: u64, }, + + // 25 + /// AddPoolReward + /// + /// * Admin only instruction. + /// * Duration is ceiled to granularity of 1 second. + /// * Can last at most 49,710 days. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Reserve account pubkey + /// * Reward mint pubkey + /// `[writable]` Uninitialized rent-exempt account that will hold reward tokens. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Rent sysvar. + /// `[]` Token program. + AddPoolReward { + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// If in the past according to the Clock sysvar then started immediately. + start_time_secs: u64, + /// Must be larger than start. + end_time_secs: u64, + /// Must have at least this many tokens in the source account. + token_amount: u64, + }, + + // 26 + /// ClosePoolReward + /// + /// * Admin only instruction. + /// * Can only be called if reward period is over. + /// * Can only be called if all users claimed rewards. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Reserve account pubkey + /// * Reward mint pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Rent sysvar. + /// `[]` Token program. + ClosePoolReward { + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// Identifies a reward within a reserve's deposits/borrows rewards. + pool_reward_index: u64, + }, + + // 27 + /// CancelPoolReward + /// + /// * Admin only instruction. + /// * Changed the endtime of the reward to the current time. + /// * Claims unallocated rewards to the admin signer. + /// + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[writable]` Reward token account owned by signer + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Reserve account pubkey + /// * Reward mint pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[signer]` Lending market owner. + /// `[]` Rent sysvar. + /// `[]` Token program. + CancelPoolReward { + /// Whether this reward applies to deposits or borrows + position_kind: PositionKind, + /// Identifies a reward within a reserve's deposits/borrows rewards. + pool_reward_index: u64, + }, } impl LendingInstruction { @@ -786,6 +871,34 @@ impl LendingInstruction { let (liquidity_amount, _rest) = Self::unpack_u64(rest)?; Self::DonateToReserve { liquidity_amount } } + 25 => { + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (start_time_secs, rest) = Self::unpack_u64(rest)?; + let (end_time_secs, rest) = Self::unpack_u64(rest)?; + let (token_amount, _rest) = Self::unpack_u64(rest)?; + Self::AddPoolReward { + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } + } + 26 => { + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; + Self::ClosePoolReward { + position_kind, + pool_reward_index, + } + } + 27 => { + let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; + let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; + Self::CancelPoolReward { + position_kind, + pool_reward_index, + } + } _ => { msg!("Instruction cannot be unpacked"); return Err(LendingError::InstructionUnpackError.into()); @@ -835,6 +948,15 @@ impl LendingInstruction { Ok((value, rest)) } + fn unpack_try_from_u8(input: &[u8]) -> Result<(T, &[u8]), ProgramError> + where + T: TryFrom, + ProgramError: From<>::Error>, + { + let (byte, rest) = Self::unpack_u8(input)?; + Ok((T::try_from(byte)?, rest)) + } + fn unpack_bytes32(input: &[u8]) -> Result<(&[u8; 32], &[u8]), ProgramError> { if input.len() < 32 { msg!("32 bytes cannot be unpacked"); @@ -1085,6 +1207,34 @@ impl LendingInstruction { buf.push(24); buf.extend_from_slice(&liquidity_amount.to_le_bytes()); } + Self::AddPoolReward { + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } => { + buf.push(25); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&start_time_secs.to_le_bytes()); + buf.extend_from_slice(&end_time_secs.to_le_bytes()); + buf.extend_from_slice(&token_amount.to_le_bytes()); + } + Self::ClosePoolReward { + position_kind, + pool_reward_index, + } => { + buf.push(26); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&pool_reward_index.to_le_bytes()); + } + Self::CancelPoolReward { + position_kind, + pool_reward_index, + } => { + buf.push(27); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); + buf.extend_from_slice(&pool_reward_index.to_le_bytes()); + } } buf } diff --git a/token-lending/sdk/src/state/mod.rs b/token-lending/sdk/src/state/mod.rs index e5b96b7cd73..c18c9793dfa 100644 --- a/token-lending/sdk/src/state/mod.rs +++ b/token-lending/sdk/src/state/mod.rs @@ -3,6 +3,7 @@ mod last_update; mod lending_market; mod lending_market_metadata; +mod liquidity_mining; mod obligation; mod rate_limiter; mod reserve; @@ -10,6 +11,7 @@ mod reserve; pub use last_update::*; pub use lending_market::*; pub use lending_market_metadata::*; +pub use liquidity_mining::*; pub use obligation::*; pub use rate_limiter::*; pub use reserve::*; diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index e17bcfa2f21..d4bbe50e5e8 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -656,6 +656,18 @@ impl Pack for Obligation { } } +impl TryFrom for PositionKind { + type Error = ProgramError; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(PositionKind::Deposit), + 1 => Ok(PositionKind::Borrow), + _ => Err(LendingError::InstructionUnpackError.into()), + } + } +} + #[cfg(test)] mod test { use super::*; From 2587270183b355f876c47067ef72edb4c5fb5388 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 14 Mar 2025 13:07:18 +0100 Subject: [PATCH 04/19] [Liquidity Mining] Math for reward accrual (3) (#200) --- .../program/src/processor/liquidity_mining.rs | 5 +- .../sdk/src/state/liquidity_mining.rs | 194 +++++++++++++++++- 2 files changed, 192 insertions(+), 7 deletions(-) diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 76defa6c5e6..ed8b274ed06 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -38,9 +38,6 @@ use solend_sdk::{ use spl_token::state::Account as TokenAccount; use std::convert::TryInto; -/// Cannot create a reward shorter than this. -const MIN_REWARD_PERIOD_SECS: u64 = 3_600; - /// # Accounts /// /// See [add_pool_reward::AddPoolRewardAccounts::from_unchecked_iter] for a list @@ -252,6 +249,8 @@ fn reward_vault_authority_seeds<'keys>( } mod add_pool_reward { + use solend_sdk::state::MIN_REWARD_PERIOD_SECS; + use super::*; /// Use [Self::new] to validate the parameters. diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index c4a73849ebc..860c0b24e4f 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -1,5 +1,11 @@ -use crate::math::Decimal; -use solana_program::pubkey::Pubkey; +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, +}; +use solana_program::{clock::Clock, program_error::ProgramError, pubkey::Pubkey}; + +/// Cannot create a reward shorter than this. +pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; /// Determines the size of [PoolRewardManager] /// TODO: This should be configured when we're dealing with migrations later but we should aim for 50. @@ -28,8 +34,8 @@ pub struct PoolRewardManager { /// 1. Consider the associated slot locked forever /// 2. Go back to 0. /// -/// Given that one reward lasts at least 1 hour we've got at least half a -/// million years before we need to worry about wrapping in a single slot. +/// Given that one reward lasts at [MIN_REWARD_PERIOD_SECS] we've got at least +/// half a million years before we need to worry about wrapping in a single slot. /// I'd call that someone else's problem. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolRewardId(pub u32); @@ -127,8 +133,158 @@ pub struct UserReward { pub cumulative_rewards_per_share: Decimal, } +impl PoolRewardManager { + /// Should be updated before any interaction with rewards. + fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> { + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + + if self.last_update_time_secs >= curr_unix_timestamp_secs { + return Ok(()); + } + + if self.total_shares == 0 { + self.last_update_time_secs = curr_unix_timestamp_secs; + return Ok(()); + } + + let last_update_time_secs = self.last_update_time_secs; + + // get rewards that started already and did not finish yet + let running_rewards = self + .pool_rewards + .iter_mut() + .filter_map(|r| match r { + PoolRewardSlot::Occupied(reward) => Some(reward), + _ => None, + }) + .filter(|r| curr_unix_timestamp_secs > r.start_time_secs) + .filter(|r| last_update_time_secs < (r.start_time_secs + r.duration_secs as u64)); + + for reward in running_rewards { + let end_time_secs = reward.start_time_secs + reward.duration_secs as u64; + let time_passed_secs = curr_unix_timestamp_secs + .min(end_time_secs) + .checked_sub(reward.start_time_secs.max(last_update_time_secs)) + .ok_or(LendingError::MathOverflow)?; + + // When adding a reward we assert that a reward lasts for at least [MIN_REWARD_PERIOD_SECS]. + // Hence this won't error on overflow nor on division by zero. + let unlocked_rewards = Decimal::from(reward.total_rewards) + .try_mul(Decimal::from(time_passed_secs))? + .try_div(Decimal::from(end_time_secs - reward.start_time_secs))?; + + reward.allocated_rewards = reward.allocated_rewards.try_add(unlocked_rewards)?; + + reward.cumulative_rewards_per_share = reward + .cumulative_rewards_per_share + .try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?; + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } +} + +enum CreatingNewUserRewardManager { + /// If we are creating a [UserRewardManager] then we want to populate it. + Yes, + No, +} + +impl UserRewardManager { + /// Should be updated before any interaction with rewards. + /// + /// # Assumption + /// Invoker has checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + fn update( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + creating_new_reward_manager: CreatingNewUserRewardManager, + ) -> Result<(), ProgramError> { + pool_reward_manager.update(clock)?; + + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + + if matches!( + creating_new_reward_manager, + CreatingNewUserRewardManager::No + ) && curr_unix_timestamp_secs == self.last_update_time_secs + { + return Ok(()); + } + + self.rewards + .resize_with(pool_reward_manager.pool_rewards.len(), || None); + + for (reward_index, pool_reward) in pool_reward_manager.pool_rewards.iter_mut().enumerate() { + let PoolRewardSlot::Occupied(pool_reward) = pool_reward else { + // no reward to track + continue; + }; + + let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; + + match self.rewards.get_mut(reward_index) { + None => unreachable!("We've just resized the rewards."), + Some(None) if self.last_update_time_secs > end_time_secs => { + // reward period ended, skip + } + Some(None) => { + // user did not yet start accruing rewards + + let new_user_reward = UserReward { + pool_reward_id: pool_reward.id, + cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, + earned_rewards: if self.last_update_time_secs <= pool_reward.start_time_secs + { + pool_reward + .cumulative_rewards_per_share + .try_mul(Decimal::from(self.share))? + } else { + debug_assert!(matches!( + creating_new_reward_manager, + CreatingNewUserRewardManager::Yes + )); + Decimal::zero() + }, + }; + + // we resized this vector to match the pool rewards + self.rewards[reward_index] = Some(new_user_reward); + + pool_reward.num_user_reward_managers += 1; + } + Some(Some(user_reward)) => { + // user is already accruing rewards, add the difference + + let new_reward_amount = pool_reward + .cumulative_rewards_per_share + .try_sub(user_reward.cumulative_rewards_per_share)? + .try_mul(Decimal::from(self.share))?; + + user_reward.earned_rewards = + user_reward.earned_rewards.try_add(new_reward_amount)?; + + user_reward.cumulative_rewards_per_share = + pool_reward.cumulative_rewards_per_share; + } + } + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } +} + #[cfg(test)] mod tests { + //! TODO: Rewrite these tests from their Suilend counterparts. + //! TODO: Calculate test coverage and add tests for missing branches. + use super::*; #[test] @@ -144,4 +300,34 @@ mod tests { println!("assert {required_realloc} <= {MAX_REALLOC}"); assert!(required_realloc <= MAX_REALLOC); } + + #[test] + fn it_tests_pool_reward_manager_basic() { + // TODO: rewrite Suilend "test_pool_reward_manager_basic" test + } + + #[test] + fn it_tests_pool_reward_manager_multiple_rewards() { + // TODO: rewrite Suilend "test_pool_reward_manager_multiple_rewards" + } + + #[test] + fn it_tests_pool_reward_zero_share() { + // TODO: rewrite Suilend "test_pool_reward_manager_zero_share" + } + + #[test] + fn it_tests_pool_reward_manager_auto_farm() { + // TODO: rewrite Suilend "test_pool_reward_manager_auto_farm" + } + + #[test] + fn it_tests_add_too_many_pool_rewards() { + // TODO: rewrite Suilend "test_add_too_many_pool_rewards" + } + + #[test] + fn it_tests_pool_reward_manager_cancel_and_close_regression() { + // TODO: rewrite Suilend "test_pool_reward_manager_cancel_and_close_regression" + } } From 389a7d835abd93ce37c7138c7489de689c845012 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 14 Mar 2025 13:10:09 +0100 Subject: [PATCH 05/19] [Liquidity Mining] Reserve packing & unpacking (4) (#202) --- .editorconfig | 8 + .../workflows/pull-request-token-lending.yml | 14 +- .github/workflows/pull-request.yml | 14 +- .mocharc.yml | 1 + Anchor.toml | 27 +- Cargo.lock | 25 +- Cargo.toml | 8 +- package.json | 20 + token-lending/cli/Cargo.toml | 10 +- token-lending/cli/src/main.rs | 43 + token-lending/program/Cargo.toml | 4 +- token-lending/program/src/processor.rs | 6 + .../program/src/processor/liquidity_mining.rs | 134 ++ token-lending/sdk/Cargo.toml | 10 +- token-lending/sdk/src/instruction.rs | 33 + .../sdk/src/state/liquidity_mining.rs | 285 +++- token-lending/sdk/src/state/reserve.rs | 267 ++-- token-lending/tests/liquidity-mining.ts | 66 + tsconfig.json | 10 + yarn.lock | 1181 +++++++++++++++++ 20 files changed, 1988 insertions(+), 178 deletions(-) create mode 100644 .editorconfig create mode 100644 .mocharc.yml create mode 100644 package.json create mode 100644 token-lending/tests/liquidity-mining.ts create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..ccafd388660 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.ts] +indent_size = 2 diff --git a/.github/workflows/pull-request-token-lending.yml b/.github/workflows/pull-request-token-lending.yml index f1c1c1ff844..5e88e428059 100644 --- a/.github/workflows/pull-request-token-lending.yml +++ b/.github/workflows/pull-request-token-lending.yml @@ -3,13 +3,13 @@ name: Token Lending Pull Request on: pull_request: paths: - - 'token-lending/**' - - 'token/**' + - "token-lending/**" + - "token/**" push: branches: [master] paths: - - 'token-lending/**' - - 'token/**' + - "token-lending/**" + - "token/**" jobs: cargo-test-bpf: @@ -30,20 +30,20 @@ jobs: override: true profile: minimal - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git key: cargo-build-${{ hashFiles('**/Cargo.lock') }}-${{ env.RUST_STABLE}} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/rustfilt key: cargo-bpf-bins-${{ runner.os }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cache diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2221f07590c..badf172312b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -3,11 +3,11 @@ name: Pull Request on: pull_request: paths-ignore: - - 'docs/**' + - "docs/**" push: branches: [master, upcoming] paths-ignore: - - 'docs/**' + - "docs/**" jobs: all_github_action_checks: @@ -59,7 +59,7 @@ jobs: profile: minimal components: clippy - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/registry @@ -96,7 +96,7 @@ jobs: override: true profile: minimal - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/registry @@ -104,13 +104,13 @@ jobs: # target # Removed due to build dependency caching conflicts key: cargo-build-${{ hashFiles('**/Cargo.lock') }}-${{ env.RUST_STABLE}} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/bin/rustfilt key: cargo-bpf-bins-${{ runner.os }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cache @@ -143,7 +143,7 @@ jobs: override: true profile: minimal - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: path: | ~/.cargo/registry diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 00000000000..5c2191843de --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1 @@ +bail: true diff --git a/Anchor.toml b/Anchor.toml index c3690eaf173..bd1b269975a 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,14 +1,31 @@ -anchor_version = "0.13.2" +[toolchain] +package_manager = "yarn" +anchor_version = "0.28.0" + +[features] +resolution = true +skip-lint = false [workspace] -members = [ - "token-lending/program", - "token-lending/brick", -] +members = ["token-lending/program", "token-lending/brick"] [provider] cluster = "mainnet" wallet = "~/.config/solana/id.json" +[scripts] +test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 token-lending/tests/**/*.ts" + [programs.mainnet] spl_token_lending = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo" + +[programs.localnet] +solend_program = "So1endDq2YkqhipRh3WViPa8hdiSpxWy6z3Z6tMCpAo" + +[test.validator] +# we use some mainnet accounts for tests +url = "https://api.mainnet-beta.solana.com" + +[[test.validator.clone]] +# Solend Main Pool - (USDC) Reserve State +address = "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw" diff --git a/Cargo.lock b/Cargo.lock index 63624df360b..37fe074561d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -780,18 +780,18 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.5.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.6.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" @@ -2444,12 +2444,6 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" -[[package]] -name = "libm" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" - [[package]] name = "libredox" version = "0.1.3" @@ -2900,7 +2894,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -3368,9 +3361,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.5.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c2511913b88df1637da85cc8d96ec8e43a3f8bb8ccb71ee1ac240d6f3df58d" +checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" dependencies = [ "bit-set", "bit-vec", @@ -5419,7 +5412,7 @@ dependencies = [ [[package]] name = "solend-program" -version = "2.0.2" +version = "2.1.0" dependencies = [ "anchor-lang 0.28.0", "assert_matches", @@ -5448,7 +5441,7 @@ dependencies = [ [[package]] name = "solend-program-cli" -version = "2.0.2" +version = "2.1.0" dependencies = [ "bincode", "clap 2.34.0", @@ -5469,7 +5462,7 @@ dependencies = [ [[package]] name = "solend-sdk" -version = "2.0.2" +version = "2.1.0" dependencies = [ "arrayref", "assert_matches", diff --git a/Cargo.toml b/Cargo.toml index 7daa3e7968f..829068fcb4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,12 @@ members = [ "token-lending/cli", "token-lending/program", "token-lending/sdk", - "token-lending/brick" -, "token-lending/oracles"] + "token-lending/brick", + "token-lending/oracles", +] + +[workspace.package] +version = "2.1.0" [profile.dev] split-debuginfo = "unpacked" diff --git a/package.json b/package.json new file mode 100644 index 00000000000..43611d9784c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "license": "ISC", + "scripts": { + "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", + "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" + }, + "dependencies": { + "@coral-xyz/anchor": "^0.28.0" + }, + "devDependencies": { + "chai": "^4.3.4", + "mocha": "^9.0.3", + "ts-mocha": "^10.0.0", + "@types/bn.js": "^5.1.0", + "@types/chai": "^4.3.0", + "@types/mocha": "^9.0.0", + "typescript": "^5.7.3", + "prettier": "^2.6.2" + } +} diff --git a/token-lending/cli/Cargo.toml b/token-lending/cli/Cargo.toml index 8888352835b..a59aa534750 100644 --- a/token-lending/cli/Cargo.toml +++ b/token-lending/cli/Cargo.toml @@ -1,12 +1,12 @@ [package] +name = "solend-program-cli" +version.workspace = true authors = ["Solend Maintainers "] description = "Solend Program CLI" edition = "2018" homepage = "https://solend.fi" license = "Apache-2.0" -name = "solend-program-cli" repository = "https://github.com/solendprotocol/solana-program-library" -version = "2.0.2" [dependencies] clap = "=2.34.0" @@ -16,9 +16,9 @@ solana-client = "1.14.10" solana-logger = "1.14.10" solana-sdk = "1.14.10" solana-program = "1.14.10" -solend-sdk = { path="../sdk" } -solend-program = { path="../program", features = [ "no-entrypoint" ] } -spl-token = { version = "3.3.0", features=["no-entrypoint"] } +solend-sdk = { path = "../sdk" } +solend-program = { path = "../program", features = ["no-entrypoint"] } +spl-token = { version = "3.3.0", features = ["no-entrypoint"] } spl-associated-token-account = "1.0" solana-account-decoder = "1.14.10" reqwest = { version = "0.12.2", features = ["blocking", "json"] } diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index db0658a0068..8c30b314b91 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -11,6 +11,7 @@ use solend_program::{ instruction::set_lending_market_owner_and_config, state::{validate_reserve_config, RateLimiterConfig}, }; +use solend_sdk::instruction::upgrade_reserve_to_v2_1_0; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -768,6 +769,20 @@ fn main() { .help("Risk authority address"), ) ) + .subcommand( + SubCommand::with_name("upgrade-reserve") + .about("Migrate reserve to version 2.1.0") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + + ) .subcommand( SubCommand::with_name("update-reserve") .about("Update a reserve config") @@ -1324,6 +1339,11 @@ fn main() { risk_authority_pubkey, ) } + ("upgrade-reserve", Some(arg_matches)) => { + let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); + + command_upgrade_reserve_to_v2_1_0(&mut config, reserve_pubkey) + } ("update-reserve", Some(arg_matches)) => { let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); let lending_market_owner_keypair = @@ -1973,6 +1993,29 @@ fn command_set_lending_market_owner_and_config( Ok(()) } +fn command_upgrade_reserve_to_v2_1_0(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult { + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = Message::new_with_blockhash( + &[ + ComputeBudgetInstruction::set_compute_unit_price(30101), + upgrade_reserve_to_v2_1_0( + config.lending_program_id, + reserve_pubkey, + config.fee_payer.pubkey(), + ), + ], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash); + + send_transaction(config, transaction)?; + + Ok(()) +} + #[allow(clippy::too_many_arguments, clippy::unnecessary_unwrap)] fn command_update_reserve( config: &mut Config, diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index a58c4d08c06..44661e3a6ac 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program" -version = "2.0.2" +version.workspace = true description = "Solend Program" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" @@ -18,7 +18,7 @@ bytemuck = "1.5.1" solana-program = "=1.16.20" solend-sdk = { path = "../sdk" } oracles = { path = "../oracles" } -spl-token = { version = "3.3.0", features=["no-entrypoint"] } +spl-token = { version = "3.3.0", features = ["no-entrypoint"] } static_assertions = "1.1.0" [dev-dependencies] diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 470558d4f38..4006289dc76 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -244,6 +244,12 @@ pub fn process_instruction( accounts, ) } + + // temporary ix for upgrade + LendingInstruction::UpgradeReserveToV2_1_0 => { + msg!("Instruction: Upgrade Reserve to v2.1.0"); + liquidity_mining::upgrade_reserve(program_id, accounts) + } } } diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index ed8b274ed06..1c802e5bdfd 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -26,9 +26,11 @@ use solana_program::{ clock::Clock, entrypoint::ProgramResult, msg, + program::invoke, program_error::ProgramError, pubkey::Pubkey, rent::Rent, + system_instruction, sysvar::Sysvar, }; use solend_sdk::{ @@ -37,6 +39,7 @@ use solend_sdk::{ }; use spl_token::state::Account as TokenAccount; use std::convert::TryInto; +use upgrade_reserve::UpgradeReserveAccounts; /// # Accounts /// @@ -217,6 +220,75 @@ pub(crate) fn process_close_pool_reward( Ok(()) } +/// Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. +/// Fails if reserve was not sized as @v2.0.2. +/// +/// Until this ix is called for a [Reserve] account, all other ixs that try to +/// unpack the [Reserve] will fail due to size mismatch. +/// +/// # Accounts +/// +/// See [upgrade_reserve::UpgradeReserveAccounts::from_unchecked_iter] for a list +/// of accounts and their constraints. +/// +/// # Effects +/// +/// 1. Takes payer's lamports and pays for the rent increase. +/// 2. Reallocates the reserve account to the latest size. +/// 3. Repacks the reserve account. +pub(crate) fn upgrade_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts = UpgradeReserveAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // + // 1. + // + + let current_rent = accounts.reserve_info.lamports(); + let new_rent = Rent::get()?.minimum_balance(Reserve::LEN); + + if let Some(extra_rent) = new_rent.checked_sub(current_rent) { + // some reserves have more rent than necessary, let's not assume that + // the payer always needs to add more rent + + invoke( + &system_instruction::transfer( + accounts.payer.key, + accounts.reserve_info.key, + extra_rent, + ), + &[ + accounts.payer.clone(), + accounts.reserve_info.clone(), + accounts.system_program.clone(), + ], + )?; + } + + // + // 2. + // + + // From the [AccountInfo::realloc] docs: + // + // > Memory used to grow is already zero-initialized upon program entrypoint + // > and re-zeroing it wastes compute units. If within the same call a program + // > reallocs from larger to smaller and back to larger again the new space + // > could contain stale data. Pass true for zero_init in this case, + // > otherwise compute units will be wasted re-zero-initializing. + let zero_init = false; + accounts.reserve_info.realloc(Reserve::LEN, zero_init)?; + + // + // 3. + // + + // sanity checks pack and unpack reserves is ok + let reserve = Reserve::unpack(&accounts.reserve_info.data.borrow())?; + Reserve::pack(reserve, &mut accounts.reserve_info.data.borrow_mut())?; + + Ok(()) +} + /// Unpacks a spl_token [TokenAccount]. fn unpack_token_account(data: &[u8]) -> Result { TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount) @@ -645,6 +717,68 @@ mod close_pool_reward { } } +mod upgrade_reserve { + use solend_sdk::state::RESERVE_LEN_V2_0_2; + + use super::*; + + pub(super) struct UpgradeReserveAccounts<'a, 'info> { + /// Reserve sized as v2.0.2. + /// + /// ✅ belongs to this program + /// ✅ is sized [RESERVE_LEN_V2_0_2], ie. for sure [Reserve] account + pub(super) reserve_info: &'a AccountInfo<'info>, + /// The pool fella who pays for this. + /// + /// ✅ is a signer + pub(super) payer: &'a AccountInfo<'info>, + /// The system program. + /// + /// ✅ is the system program + pub(super) system_program: &'a AccountInfo<'info>, + + _priv: (), + } + + impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let payer = next_account_info(iter)?; + let system_program = next_account_info(iter)?; + + if !payer.is_signer { + msg!("Payer provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + if reserve_info.owner != program_id { + msg!("Reserve provided must be owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + if reserve_info.data_len() != RESERVE_LEN_V2_0_2 { + msg!("Reserve provided must be sized as v2.0.2"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if system_program.key != &solana_program::system_program::id() { + msg!("System program provided must be the system program"); + return Err(LendingError::InvalidAccountInput.into()); + } + + Ok(Self { + payer, + reserve_info, + system_program, + _priv: (), + }) + } + } +} + /// Common checks within the admin ixs are: /// /// * ✅ `reserve_info` belongs to this program diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index f969a318766..7ebce7c8d64 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-sdk" -version = "2.0.2" +version.workspace = true description = "Solend Sdk" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" @@ -14,7 +14,7 @@ bytemuck = "1.5.1" num-derive = "0.3" num-traits = "0.2" solana-program = ">=1.9" -spl-token = { version = "3.2.0", features=["no-entrypoint"] } +spl-token = { version = "3.2.0", features = ["no-entrypoint"] } static_assertions = "1.1.0" thiserror = "1.0" uint = "=0.9.1" @@ -23,11 +23,11 @@ uint = "=0.9.1" assert_matches = "1.5.0" base64 = "0.13" log = "0.4.14" -proptest = "1.0" -solana-sdk = ">=1.9" +proptest = "1.6" +rand = "0.8.5" serde = ">=1.0.140" serde_yaml = "0.8" -rand = "0.8.5" +solana-sdk = ">=1.9" [lib] crate-type = ["cdylib", "lib"] diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index de75c22a66e..5302817f46b 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -613,6 +613,18 @@ pub enum LendingInstruction { /// Identifies a reward within a reserve's deposits/borrows rewards. pool_reward_index: u64, }, + + // 255 + /// UpgradeReserveToV2_1_0 + /// + /// Temporary ix which upgrades reserves from @2.0.2 to @2.1.0 with + /// liquidity mining feature. + /// Once all reserves are upgraded this ix is not necessary any more. + /// + /// `[writable]` Reserve account. + /// `[writable, signer]` Fee payer. + /// `[]` System program. + UpgradeReserveToV2_1_0, } impl LendingInstruction { @@ -899,6 +911,7 @@ impl LendingInstruction { pool_reward_index, } } + 255 => Self::UpgradeReserveToV2_1_0, _ => { msg!("Instruction cannot be unpacked"); return Err(LendingError::InstructionUnpackError.into()); @@ -1235,6 +1248,9 @@ impl LendingInstruction { buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); } + Self::UpgradeReserveToV2_1_0 => { + buf.push(255); + } } buf } @@ -2047,6 +2063,23 @@ pub fn donate_to_reserve( } } +/// Creates a `UpgradeReserveToV2_1_0` instruction. +pub fn upgrade_reserve_to_v2_1_0( + program_id: Pubkey, + reserve_pubkey: Pubkey, + fee_payer: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve_pubkey, false), + AccountMeta::new(fee_payer, true), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: LendingInstruction::UpgradeReserveToV2_1_0.pack(), + } +} + #[cfg(test)] mod test { use super::*; diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 860c0b24e4f..5fdbfe07b74 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -1,19 +1,28 @@ use crate::{ error::LendingError, math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, + state::unpack_decimal, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use solana_program::program_pack::{Pack, Sealed}; +use solana_program::{ + clock::Clock, + program_error::ProgramError, + pubkey::{Pubkey, PUBKEY_BYTES}, }; -use solana_program::{clock::Clock, program_error::ProgramError, pubkey::Pubkey}; -/// Cannot create a reward shorter than this. -pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; +use super::pack_decimal; /// Determines the size of [PoolRewardManager] -/// TODO: This should be configured when we're dealing with migrations later but we should aim for 50. -const MAX_REWARDS: usize = 44; +const MAX_REWARDS: usize = 50; + +/// Cannot create a reward shorter than this. +pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; /// Each reserve has two managers: /// - one for deposits /// - one for borrows +#[derive(Clone, Debug, PartialEq)] pub struct PoolRewardManager { /// Is updated when we change user shares in the reserve. pub total_shares: u64, @@ -46,6 +55,7 @@ pub struct PoolRewardId(pub u32); /// reward is vacant or not to save space. /// /// If the pubkey is eq to default pubkey then slot is vacant. +#[derive(Clone, Debug, PartialEq)] pub enum PoolRewardSlot { /// New reward can be added to this slot. Vacant { @@ -53,10 +63,22 @@ pub enum PoolRewardSlot { last_pool_reward_id: PoolRewardId, }, /// Reward has not been closed yet. - Occupied(PoolReward), + /// + /// We box the [PoolReward] to avoid stack overflow. + Occupied(Box), } /// Tracks rewards in a specific mint over some period of time. +/// +/// # Reward cancellation +/// In Suilend we also store the amount of rewards that have been made available +/// to users already. +/// We keep adding `(total_rewards * time_passed) / (total_time)` every +/// time someone interacts with the manager. +/// This value is used to transfer the unallocated rewards to the admin. +/// However, this can be calculated dynamically which avoids storing extra +/// [Decimal] on each [PoolReward]. +#[derive(Clone, Debug, Default, PartialEq)] pub struct PoolReward { /// Unique ID for this slot that has never been used before, and will never /// be used again. @@ -77,16 +99,13 @@ pub struct PoolReward { /// There's a permission-less ix with which user rewards can be distributed /// that's used for cranking remaining rewards. pub num_user_reward_managers: u64, - /// Amount of rewards that have been made available to users. - /// - /// We keep adding `(total_rewards * time_passed) / (total_time)` every - /// time someone interacts with the manager - /// ([update_pool_reward_manager]). - pub allocated_rewards: Decimal, /// We keep adding `(unlocked_rewards) / (total_shares)` every time /// someone interacts with the manager ([update_pool_reward_manager]) /// where /// `unlocked_rewards = (total_rewards * time_passed) / (total_time)` + /// + /// # (Un)Packing + /// We only store 16 most significant digits. pub cumulative_rewards_per_share: Decimal, } @@ -173,8 +192,6 @@ impl PoolRewardManager { .try_mul(Decimal::from(time_passed_secs))? .try_div(Decimal::from(end_time_secs - reward.start_time_secs))?; - reward.allocated_rewards = reward.allocated_rewards.try_add(unlocked_rewards)?; - reward.cumulative_rewards_per_share = reward .cumulative_rewards_per_share .try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?; @@ -280,24 +297,222 @@ impl UserRewardManager { } } +impl PoolReward { + const LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES + 8 + 4 + 8 + 8 + 16; +} + +impl PoolRewardId { + const LEN: usize = std::mem::size_of::(); +} + +impl Default for PoolRewardManager { + fn default() -> Self { + Self { + total_shares: 0, + last_update_time_secs: 0, + pool_rewards: std::array::from_fn(|_| PoolRewardSlot::default()), + } + } +} + +impl Default for PoolRewardSlot { + fn default() -> Self { + Self::Vacant { + last_pool_reward_id: PoolRewardId(0), + } + } +} + +impl PoolRewardManager { + #[inline(never)] + pub(crate) fn unpack_to_box(input: &[u8]) -> Result, ProgramError> { + Ok(Box::new(PoolRewardManager::unpack_from_slice(input)?)) + } +} + +impl Sealed for PoolRewardManager {} + +impl Pack for PoolRewardManager { + /// total_shares + last_update_time_secs + pool_rewards. + const LEN: usize = 8 + 8 + MAX_REWARDS * PoolReward::LEN; + + fn pack_into_slice(&self, output: &mut [u8]) { + output[0..8].copy_from_slice(&self.total_shares.to_le_bytes()); + output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + + for (index, pool_reward_slot) in self.pool_rewards.iter().enumerate() { + let offset = 16 + index * PoolReward::LEN; + let raw_pool_reward = array_mut_ref![output, offset, PoolReward::LEN]; + + let ( + dst_id, + dst_vault, + dst_start_time_secs, + dst_duration_secs, + dst_total_rewards, + dst_num_user_reward_managers, + dst_cumulative_rewards_per_share_wads, + ) = mut_array_refs![ + raw_pool_reward, + PoolRewardId::LEN, + PUBKEY_BYTES, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + let ( + id, + vault, + start_time_secs, + duration_secs, + total_rewards, + num_user_reward_managers, + cumulative_rewards_per_share, + ) = match pool_reward_slot { + PoolRewardSlot::Vacant { + last_pool_reward_id, + } => ( + *last_pool_reward_id, + Pubkey::default(), + 0u64, + 0u32, + 0u64, + 0u64, + Decimal::zero(), + ), + PoolRewardSlot::Occupied(pool_reward) => ( + pool_reward.id, + pool_reward.vault, + pool_reward.start_time_secs, + pool_reward.duration_secs, + pool_reward.total_rewards, + pool_reward.num_user_reward_managers, + pool_reward.cumulative_rewards_per_share, + ), + }; + + dst_id.copy_from_slice(&id.0.to_le_bytes()); + dst_vault.copy_from_slice(vault.as_ref()); + *dst_start_time_secs = start_time_secs.to_le_bytes(); + *dst_duration_secs = duration_secs.to_le_bytes(); + *dst_total_rewards = total_rewards.to_le_bytes(); + *dst_num_user_reward_managers = num_user_reward_managers.to_le_bytes(); + pack_decimal( + cumulative_rewards_per_share, + dst_cumulative_rewards_per_share_wads, + ); + } + } + + #[inline(never)] + fn unpack_from_slice(input: &[u8]) -> Result { + let mut pool_reward_manager = PoolRewardManager { + total_shares: u64::from_le_bytes(*array_ref![input, 0, 8]), + last_update_time_secs: u64::from_le_bytes(*array_ref![input, 8, 8]), + ..Default::default() + }; + + for index in 0..MAX_REWARDS { + let offset = 8 + 8 + index * PoolReward::LEN; + let raw_pool_reward = array_ref![input, offset, PoolReward::LEN]; + + let ( + src_id, + src_vault, + src_start_time_secs, + src_duration_secs, + src_total_rewards, + src_num_user_reward_managers, + src_cumulative_rewards_per_share_wads, + ) = array_refs![ + raw_pool_reward, + PoolRewardId::LEN, + PUBKEY_BYTES, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + let vault = Pubkey::new_from_array(*src_vault); + let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id)); + + // SAFETY: ok to assign because we know the index is less than length + pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { + PoolRewardSlot::Vacant { + last_pool_reward_id: pool_reward_id, + } + } else { + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: pool_reward_id, + vault, + start_time_secs: u64::from_le_bytes(*src_start_time_secs), + duration_secs: u32::from_le_bytes(*src_duration_secs), + total_rewards: u64::from_le_bytes(*src_total_rewards), + num_user_reward_managers: u64::from_le_bytes(*src_num_user_reward_managers), + cumulative_rewards_per_share: unpack_decimal( + src_cumulative_rewards_per_share_wads, + ), + })) + }; + } + + Ok(pool_reward_manager) + } +} + #[cfg(test)] mod tests { //! TODO: Rewrite these tests from their Suilend counterparts. //! TODO: Calculate test coverage and add tests for missing branches. use super::*; + use proptest::prelude::*; + use rand::Rng; + + fn pool_reward_manager_strategy() -> impl Strategy { + (0..100u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) + } + + proptest! { + #[test] + fn it_packs_and_unpacks(pool_reward_manager in pool_reward_manager_strategy()) { + let mut packed = vec![0u8; PoolRewardManager::LEN]; + Pack::pack_into_slice(&pool_reward_manager, &mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + prop_assert_eq!(pool_reward_manager, unpacked); + } + } + + #[test] + fn it_unpacks_empty_bytes_as_default() { + let packed = vec![0u8; PoolRewardManager::LEN]; + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + assert_eq!(unpacked, PoolRewardManager::default()); + + // sanity check that everything starts at 0 + let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| { + matches!( + pool_reward, + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(0) + } + ) + }); + + assert!(all_rewards_are_empty); + } #[test] fn it_fits_reserve_realloc_into_single_ix() { - const MAX_REALLOC: usize = 10 * 1024; + const MAX_REALLOC: usize = solana_program::entrypoint::MAX_PERMITTED_DATA_INCREASE; let size_of_discriminant = 1; - let const_size_of_pool_manager = 8 + 8; - let required_realloc = size_of_discriminant - + const_size_of_pool_manager - + 2 * MAX_REWARDS * std::mem::size_of::(); - - println!("assert {required_realloc} <= {MAX_REALLOC}"); + let required_realloc = size_of_discriminant * PoolRewardManager::LEN; assert!(required_realloc <= MAX_REALLOC); } @@ -330,4 +545,32 @@ mod tests { fn it_tests_pool_reward_manager_cancel_and_close_regression() { // TODO: rewrite Suilend "test_pool_reward_manager_cancel_and_close_regression" } + + impl PoolRewardManager { + pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { + Self { + total_shares: rng.gen(), + last_update_time_secs: rng.gen(), + pool_rewards: std::array::from_fn(|_| { + let is_vacant = rng.gen_bool(0.5); + + if is_vacant { + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(rng.gen()), + } + } else { + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(rng.gen()), + vault: Pubkey::new_unique(), + start_time_secs: rng.gen(), + duration_secs: rng.gen(), + total_rewards: rng.gen(), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + num_user_reward_managers: rng.gen(), + })) + } + }), + } + } + } } diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index 2e53ba3e19d..cea55c0e21f 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -60,6 +60,20 @@ pub struct Reserve { pub rate_limiter: RateLimiter, /// Attributed borrows in USD pub attributed_borrow_value: Decimal, + /// Contains liquidity mining rewards for borrows. + /// + /// Added @v2.1.0 + /// + /// TODO: measure compute units for packing/unpacking and if significant + /// then consider packing/unpacking on demand + pub borrows_pool_reward_manager: Box, + /// Contains liquidity mining rewards for deposits. + /// + /// Added @v2.1.0 + /// + /// TODO: measure compute units for packing/unpacking and if significant + /// then consider packing/unpacking on demand + pub deposits_pool_reward_manager: Box, } impl Reserve { @@ -1229,14 +1243,18 @@ impl IsInitialized for Reserve { } /// This is the size of the account _before_ LM feature was added. -const RESERVE_LEN_V1: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 +pub const RESERVE_LEN_V2_0_2: usize = 619; // 1 + 8 + 1 + 32 + 32 + 1 + 32 + 32 + 32 + 8 + 16 + 16 + 16 + 32 + 8 + 32 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 8 + 1 + 8 + 8 + 32 + 1 + 1 + 16 + 230 +/// This is the size of the account _after_ LM feature was added. +const RESERVE_LEN_V2_1_0: usize = RESERVE_LEN_V2_0_2 + PoolRewardManager::LEN * 2; + impl Pack for Reserve { - const LEN: usize = RESERVE_LEN_V1; + const LEN: usize = RESERVE_LEN_V2_1_0; // @TODO: break this up by reserve / liquidity / collateral / config https://git.io/JOCca - // @v2.1.0 TODO: pack deposits_pool_reward_manager and borrows_pool_reward_manager + // @v2.1.0: packs deposits_pool_reward_manager and borrows_pool_reward_manager + // @v2.1.0 TODO: add discriminator fn pack_into_slice(&self, output: &mut [u8]) { - let output = array_mut_ref![output, 0, RESERVE_LEN_V1]; + let output = array_mut_ref![output, 0, Reserve::LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( version, @@ -1286,7 +1304,9 @@ impl Pack for Reserve { attributed_borrow_value, config_attributed_borrow_limit_open, config_attributed_borrow_limit_close, - _padding, + _padding, // TODO: use some of this for discriminator + output_for_borrows_pool_reward_manager, + output_for_deposits_pool_reward_manager, ) = mut_array_refs![ output, 1, @@ -1336,7 +1356,9 @@ impl Pack for Reserve { 16, 8, 8, - 49 + 49, + PoolRewardManager::LEN, + PoolRewardManager::LEN ]; // reserve @@ -1422,14 +1444,23 @@ impl Pack for Reserve { self.config.attributed_borrow_limit_close.to_le_bytes(); pack_decimal(self.attributed_borrow_value, attributed_borrow_value); + + Pack::pack_into_slice( + &*self.borrows_pool_reward_manager, + output_for_borrows_pool_reward_manager, + ); + + Pack::pack_into_slice( + &*self.deposits_pool_reward_manager, + output_for_deposits_pool_reward_manager, + ); } /// Unpacks a byte buffer into a [Reserve]. - // @v2.1.0 TODO: unpack deposits_pool_reward_manager and borrows_pool_reward_manager - // but default them if they are not present, this is part of the - // migration process + /// + // @v2.1.0 unpacks deposits_pool_reward_manager and borrows_pool_reward_manager fn unpack_from_slice(input: &[u8]) -> Result { - let input = array_ref![input, 0, RESERVE_LEN_V1]; + let input_v2_0_2 = array_ref![input, 0, RESERVE_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( version, @@ -1481,7 +1512,7 @@ impl Pack for Reserve { config_attributed_borrow_limit_close, _padding, ) = array_refs![ - input, + input_v2_0_2, 1, 8, 1, @@ -1553,110 +1584,128 @@ impl Pack for Reserve { u8::from_le_bytes(*config_max_liquidation_threshold), ); - Ok(Self { - version, - last_update: LastUpdate { - slot: u64::from_le_bytes(*last_update_slot), - stale: unpack_bool(last_update_stale)?, + let last_update = LastUpdate { + slot: u64::from_le_bytes(*last_update_slot), + stale: unpack_bool(last_update_stale)?, + }; + + let liquidity = ReserveLiquidity { + mint_pubkey: Pubkey::new_from_array(*liquidity_mint_pubkey), + mint_decimals: u8::from_le_bytes(*liquidity_mint_decimals), + supply_pubkey: Pubkey::new_from_array(*liquidity_supply_pubkey), + pyth_oracle_pubkey: Pubkey::new_from_array(*liquidity_pyth_oracle_pubkey), + switchboard_oracle_pubkey: Pubkey::new_from_array(*liquidity_switchboard_oracle_pubkey), + available_amount: u64::from_le_bytes(*liquidity_available_amount), + borrowed_amount_wads: unpack_decimal(liquidity_borrowed_amount_wads), + cumulative_borrow_rate_wads: unpack_decimal(liquidity_cumulative_borrow_rate_wads), + accumulated_protocol_fees_wads: unpack_decimal( + liquidity_accumulated_protocol_fees_wads, + ), + market_price: unpack_decimal(liquidity_market_price), + smoothed_market_price: unpack_decimal(liquidity_smoothed_market_price), + extra_market_price: match liquidity_extra_market_price_flag[0] { + 0 => None, + 1 => Some(unpack_decimal(liquidity_extra_market_price)), + _ => { + msg!("Invalid extra market price flag"); + return Err(ProgramError::InvalidAccountData); + } }, - lending_market: Pubkey::new_from_array(*lending_market), - liquidity: ReserveLiquidity { - mint_pubkey: Pubkey::new_from_array(*liquidity_mint_pubkey), - mint_decimals: u8::from_le_bytes(*liquidity_mint_decimals), - supply_pubkey: Pubkey::new_from_array(*liquidity_supply_pubkey), - pyth_oracle_pubkey: Pubkey::new_from_array(*liquidity_pyth_oracle_pubkey), - switchboard_oracle_pubkey: Pubkey::new_from_array( - *liquidity_switchboard_oracle_pubkey, - ), - available_amount: u64::from_le_bytes(*liquidity_available_amount), - borrowed_amount_wads: unpack_decimal(liquidity_borrowed_amount_wads), - cumulative_borrow_rate_wads: unpack_decimal(liquidity_cumulative_borrow_rate_wads), - accumulated_protocol_fees_wads: unpack_decimal( - liquidity_accumulated_protocol_fees_wads, - ), - market_price: unpack_decimal(liquidity_market_price), - smoothed_market_price: unpack_decimal(liquidity_smoothed_market_price), - extra_market_price: match liquidity_extra_market_price_flag[0] { - 0 => None, - 1 => Some(unpack_decimal(liquidity_extra_market_price)), - _ => { - msg!("Invalid extra market price flag"); - return Err(ProgramError::InvalidAccountData); - } - }, + }; + + let collateral = ReserveCollateral { + mint_pubkey: Pubkey::new_from_array(*collateral_mint_pubkey), + mint_total_supply: u64::from_le_bytes(*collateral_mint_total_supply), + supply_pubkey: Pubkey::new_from_array(*collateral_supply_pubkey), + }; + + let config = ReserveConfig { + optimal_utilization_rate, + max_utilization_rate: max( + optimal_utilization_rate, + u8::from_le_bytes(*config_max_utilization_rate), + ), + loan_to_value_ratio: u8::from_le_bytes(*config_loan_to_value_ratio), + liquidation_bonus, + max_liquidation_bonus, + liquidation_threshold, + max_liquidation_threshold, + min_borrow_rate: u8::from_le_bytes(*config_min_borrow_rate), + optimal_borrow_rate: u8::from_le_bytes(*config_optimal_borrow_rate), + max_borrow_rate, + super_max_borrow_rate: max( + max_borrow_rate as u64, + u64::from_le_bytes(*config_super_max_borrow_rate), + ), + fees: ReserveFees { + borrow_fee_wad: u64::from_le_bytes(*config_fees_borrow_fee_wad), + flash_loan_fee_wad: u64::from_le_bytes(*config_fees_flash_loan_fee_wad), + host_fee_percentage: u8::from_le_bytes(*config_fees_host_fee_percentage), }, - collateral: ReserveCollateral { - mint_pubkey: Pubkey::new_from_array(*collateral_mint_pubkey), - mint_total_supply: u64::from_le_bytes(*collateral_mint_total_supply), - supply_pubkey: Pubkey::new_from_array(*collateral_supply_pubkey), + deposit_limit: u64::from_le_bytes(*config_deposit_limit), + borrow_limit: u64::from_le_bytes(*config_borrow_limit), + fee_receiver: Pubkey::new_from_array(*config_fee_receiver), + protocol_liquidation_fee: min( + u8::from_le_bytes(*config_protocol_liquidation_fee), + // the behaviour of this variable changed in v2.0.2 and now represents a + // fraction of the total liquidation value that the protocol receives as + // a bonus. Prior to v2.0.2, this variable used to represent a percentage of of + // the liquidator's bonus that would be sent to the protocol. For safety, we + // cap the value here to MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS. + MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS, + ), + protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), + added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), + reserve_type: ReserveType::from_u8(config_asset_type[0]).unwrap(), + scaled_price_offset_bps: i64::from_le_bytes(*config_scaled_price_offset_bps), + extra_oracle_pubkey: if config_extra_oracle_pubkey == &[0; 32] { + None + } else { + Some(Pubkey::new_from_array(*config_extra_oracle_pubkey)) }, - config: ReserveConfig { - optimal_utilization_rate, - max_utilization_rate: max( - optimal_utilization_rate, - u8::from_le_bytes(*config_max_utilization_rate), - ), - loan_to_value_ratio: u8::from_le_bytes(*config_loan_to_value_ratio), - liquidation_bonus, - max_liquidation_bonus, - liquidation_threshold, - max_liquidation_threshold, - min_borrow_rate: u8::from_le_bytes(*config_min_borrow_rate), - optimal_borrow_rate: u8::from_le_bytes(*config_optimal_borrow_rate), - max_borrow_rate, - super_max_borrow_rate: max( - max_borrow_rate as u64, - u64::from_le_bytes(*config_super_max_borrow_rate), - ), - fees: ReserveFees { - borrow_fee_wad: u64::from_le_bytes(*config_fees_borrow_fee_wad), - flash_loan_fee_wad: u64::from_le_bytes(*config_fees_flash_loan_fee_wad), - host_fee_percentage: u8::from_le_bytes(*config_fees_host_fee_percentage), - }, - deposit_limit: u64::from_le_bytes(*config_deposit_limit), - borrow_limit: u64::from_le_bytes(*config_borrow_limit), - fee_receiver: Pubkey::new_from_array(*config_fee_receiver), - protocol_liquidation_fee: min( - u8::from_le_bytes(*config_protocol_liquidation_fee), - // the behaviour of this variable changed in v2.0.2 and now represents a - // fraction of the total liquidation value that the protocol receives as - // a bonus. Prior to v2.0.2, this variable used to represent a percentage of of - // the liquidator's bonus that would be sent to the protocol. For safety, we - // cap the value here to MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS. - MAX_PROTOCOL_LIQUIDATION_FEE_DECA_BPS, - ), - protocol_take_rate: u8::from_le_bytes(*config_protocol_take_rate), - added_borrow_weight_bps: u64::from_le_bytes(*config_added_borrow_weight_bps), - reserve_type: ReserveType::from_u8(config_asset_type[0]).unwrap(), - scaled_price_offset_bps: i64::from_le_bytes(*config_scaled_price_offset_bps), - extra_oracle_pubkey: if config_extra_oracle_pubkey == &[0; 32] { - None + // this field is added in v2.0.3 and we will never set it to zero. only time it'll + // the following two fields are added in v2.0.3 and we will never set it to zero. only time they will + // be zero is when we upgrade from v2.0.2 to v2.0.3. in that case, the correct + // thing to do is set the value to u64::MAX. + attributed_borrow_limit_open: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_open); + if value == 0 { + u64::MAX } else { - Some(Pubkey::new_from_array(*config_extra_oracle_pubkey)) - }, - // this field is added in v2.0.3 and we will never set it to zero. only time it'll - // the following two fields are added in v2.0.3 and we will never set it to zero. only time they will - // be zero is when we upgrade from v2.0.2 to v2.0.3. in that case, the correct - // thing to do is set the value to u64::MAX. - attributed_borrow_limit_open: { - let value = u64::from_le_bytes(*config_attributed_borrow_limit_open); - if value == 0 { - u64::MAX - } else { - value - } - }, - attributed_borrow_limit_close: { - let value = u64::from_le_bytes(*config_attributed_borrow_limit_close); - if value == 0 { - u64::MAX - } else { - value - } - }, + value + } }, + attributed_borrow_limit_close: { + let value = u64::from_le_bytes(*config_attributed_borrow_limit_close); + if value == 0 { + u64::MAX + } else { + value + } + }, + }; + + let input_v2_1_0 = array_ref![input, RESERVE_LEN_V2_0_2, PoolRewardManager::LEN * 2]; + let (input_for_borrows_pool_reward_manager, input_for_deposits_pool_reward_manager) = + array_refs![input_v2_1_0, PoolRewardManager::LEN, PoolRewardManager::LEN]; + + let borrows_pool_reward_manager = + PoolRewardManager::unpack_to_box(input_for_borrows_pool_reward_manager)?; + + let deposits_pool_reward_manager = + PoolRewardManager::unpack_to_box(input_for_deposits_pool_reward_manager)?; + + Ok(Self { + version, + last_update, + lending_market: Pubkey::new_from_array(*lending_market), + liquidity, + collateral, + config, rate_limiter: RateLimiter::unpack_from_slice(rate_limiter)?, attributed_borrow_value: unpack_decimal(attributed_borrow_value), + borrows_pool_reward_manager, + deposits_pool_reward_manager, }) } } @@ -1750,6 +1799,8 @@ mod test { }, rate_limiter: rand_rate_limiter(), attributed_borrow_value: rand_decimal(), + borrows_pool_reward_manager: Box::new(PoolRewardManager::new_rand(&mut rng)), + deposits_pool_reward_manager: Box::new(PoolRewardManager::new_rand(&mut rng)), }; let mut packed = [0u8; Reserve::LEN]; diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts new file mode 100644 index 00000000000..d3c988d6961 --- /dev/null +++ b/token-lending/tests/liquidity-mining.ts @@ -0,0 +1,66 @@ +/** + * $ anchor test --provider.cluster localnet --detach + */ + +import * as anchor from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; +import { expect } from "chai"; +import { exec } from "node:child_process"; + +describe("liquidity mining", () => { + // Configure the client to use the local cluster. + anchor.setProvider(anchor.AnchorProvider.env()); + + const TEST_RESERVE_FOR_UPGRADE = + "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"; + + it("Upgrades reserve to 2.1.0 via CLI", async () => { + // There's an ix that upgrades a reserve to 2.1.0. + // This ix is invocable via our CLI. + // In this test case for comfort and more test coverage we invoke the CLI + // command rather than crafting the ix ourselves. + + const rpcUrl = anchor.getProvider().connection.rpcEndpoint; + + const reserveBefore = await anchor + .getProvider() + .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + + expect(reserveBefore.data.length).to.eq(619); // old version data length + const expectedRentBefore = await anchor + .getProvider() + .connection.getMinimumBalanceForRentExemption(reserveBefore.data.length); + // some reserves have more rent + expect(reserveBefore.lamports).to.be.greaterThanOrEqual(expectedRentBefore); + + const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} upgrade-reserve --reserve ${TEST_RESERVE_FOR_UPGRADE}`; + console.log(`\$ ${command}`); + const cliProcess = exec(command); + + // let us observe progress + cliProcess.stderr.setEncoding("utf8"); + cliProcess.stderr.pipe(process.stderr); + + console.log("Waiting for command to finish..."); + const exitCode = await new Promise((resolve) => + cliProcess.on("exit", (code) => resolve(code)) + ); + + if (exitCode !== 0) { + cliProcess.stdout.setEncoding("utf8"); + console.log("CLI stdout", cliProcess.stdout.read()); + + throw new Error(`Command failed with exit code ${exitCode}`); + } + + const reserveAfter = await anchor + .getProvider() + .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + + expect(reserveAfter.data.length).to.eq(8651); // new version data length + const expectedRentAfter = await anchor + .getProvider() + .connection.getMinimumBalanceForRentExemption(reserveAfter.data.length); + expect(reserveAfter.lamports).to.be.greaterThanOrEqual(expectedRentAfter); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000000..cd5d2e3d062 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["mocha", "chai"], + "typeRoots": ["./node_modules/@types"], + "lib": ["es2015"], + "module": "commonjs", + "target": "es6", + "esModuleInterop": true + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000000..02cbfddcc0c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1181 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/runtime@^7.25.0": + version "7.26.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" + integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== + dependencies: + regenerator-runtime "^0.14.0" + +"@coral-xyz/anchor@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/anchor/-/anchor-0.28.0.tgz#8345c3c9186a91f095f704d7b90cd256f7e8b2dc" + integrity sha512-kQ02Hv2ZqxtWP30WN1d4xxT4QqlOXYDxmEd3k/bbneqhV3X5QMO4LAtoUFs7otxyivOgoqam5Il5qx81FuI4vw== + dependencies: + "@coral-xyz/borsh" "^0.28.0" + "@solana/web3.js" "^1.68.0" + base64-js "^1.5.1" + bn.js "^5.1.2" + bs58 "^4.0.1" + buffer-layout "^1.2.2" + camelcase "^6.3.0" + cross-fetch "^3.1.5" + crypto-hash "^1.3.0" + eventemitter3 "^4.0.7" + js-sha256 "^0.9.0" + pako "^2.0.3" + snake-case "^3.0.4" + superstruct "^0.15.4" + toml "^3.0.0" + +"@coral-xyz/borsh@^0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@coral-xyz/borsh/-/borsh-0.28.0.tgz#fa368a2f2475bbf6f828f4657f40a52102e02b6d" + integrity sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ== + dependencies: + bn.js "^5.1.2" + buffer-layout "^1.2.0" + +"@noble/curves@^1.4.2": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.8.1.tgz#19bc3970e205c99e4bdb1c64a4785706bce497ff" + integrity sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ== + dependencies: + "@noble/hashes" "1.7.1" + +"@noble/hashes@1.7.1", "@noble/hashes@^1.4.0": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.1.tgz#5738f6d765710921e7a751e00c20ae091ed8db0f" + integrity sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ== + +"@solana/buffer-layout@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz#b996235eaec15b1e0b5092a8ed6028df77fa6c15" + integrity sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA== + dependencies: + buffer "~6.0.3" + +"@solana/web3.js@^1.68.0": + version "1.98.0" + resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.98.0.tgz#21ecfe8198c10831df6f0cfde7f68370d0405917" + integrity sha512-nz3Q5OeyGFpFCR+erX2f6JPt3sKhzhYcSycBCSPkWjzSVDh/Rr1FqTVMRe58FKO16/ivTUcuJjeS5MyBvpkbzA== + dependencies: + "@babel/runtime" "^7.25.0" + "@noble/curves" "^1.4.2" + "@noble/hashes" "^1.4.0" + "@solana/buffer-layout" "^4.0.1" + agentkeepalive "^4.5.0" + bigint-buffer "^1.1.5" + bn.js "^5.2.1" + borsh "^0.7.0" + bs58 "^4.0.1" + buffer "6.0.3" + fast-stable-stringify "^1.0.0" + jayson "^4.1.1" + node-fetch "^2.7.0" + rpc-websockets "^9.0.2" + superstruct "^2.0.2" + +"@swc/helpers@^0.5.11": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + +"@types/bn.js@^5.1.0": + version "5.1.6" + resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-5.1.6.tgz#9ba818eec0c85e4d3c679518428afdf611d03203" + integrity sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w== + dependencies: + "@types/node" "*" + +"@types/chai@^4.3.0": + version "4.3.20" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.20.tgz#cb291577ed342ca92600430841a00329ba05cecc" + integrity sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ== + +"@types/connect@^3.4.33": + version "3.4.38" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" + integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== + dependencies: + "@types/node" "*" + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== + +"@types/mocha@^9.0.0": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-9.1.1.tgz#e7c4f1001eefa4b8afbd1eee27a237fee3bf29c4" + integrity sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw== + +"@types/node@*": + version "22.13.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4" + integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw== + dependencies: + undici-types "~6.20.0" + +"@types/node@^12.12.54": + version "12.20.55" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" + integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== + +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + +"@types/ws@^7.4.4": + version "7.4.7" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" + integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== + dependencies: + "@types/node" "*" + +"@types/ws@^8.2.2": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.0.tgz#8a2ec491d6f0685ceaab9a9b7ff44146236993b5" + integrity sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw== + dependencies: + "@types/node" "*" + +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + +JSONStream@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" + integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ== + dependencies: + jsonparse "^1.2.0" + through ">=2.2.7 <3" + +agentkeepalive@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +arrify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base-x@^3.0.2: + version "3.0.11" + resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.11.tgz#40d80e2a1aeacba29792ccc6c5354806421287ff" + integrity sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA== + dependencies: + safe-buffer "^5.0.1" + +base64-js@^1.3.1, base64-js@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bigint-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/bigint-buffer/-/bigint-buffer-1.1.5.tgz#d038f31c8e4534c1f8d0015209bf34b4fa6dd442" + integrity sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA== + dependencies: + bindings "^1.3.0" + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +bindings@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bn.js@^5.1.2, bn.js@^5.2.0, bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + +borsh@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/borsh/-/borsh-0.7.0.tgz#6e9560d719d86d90dc589bca60ffc8a6c51fec2a" + integrity sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA== + dependencies: + bn.js "^5.2.0" + bs58 "^4.0.0" + text-encoding-utf-8 "^1.0.2" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +bs58@^4.0.0, bs58@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/bs58/-/bs58-4.0.1.tgz#be161e76c354f6f788ae4071f63f34e8c4f0a42a" + integrity sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw== + dependencies: + base-x "^3.0.2" + +buffer-from@^1.0.0, buffer-from@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +buffer-layout@^1.2.0, buffer-layout@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/buffer-layout/-/buffer-layout-1.2.2.tgz#b9814e7c7235783085f9ca4966a0cfff112259d5" + integrity sha512-kWSuLN694+KTk8SrYvCqwP2WcgQjoRCiF5b4QDvkkz8EmgD+aWAIceGFKMIAdmF/pH+vpgNV3d3kAKorcdAmWA== + +buffer@6.0.3, buffer@^6.0.3, buffer@~6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bufferutil@^4.0.1: + version "4.0.9" + resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.9.tgz#6e81739ad48a95cad45a279588e13e95e24a800a" + integrity sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw== + dependencies: + node-gyp-build "^4.3.0" + +camelcase@^6.0.0, camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chai@^4.3.4: + version "4.5.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" + integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.3" + deep-eql "^4.1.3" + get-func-name "^2.0.2" + loupe "^2.3.6" + pathval "^1.1.1" + type-detect "^4.1.0" + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +check-error@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" + integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + dependencies: + get-func-name "^2.0.2" + +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^2.20.3: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cross-fetch@^3.1.5: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" + integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== + dependencies: + node-fetch "^2.7.0" + +crypto-hash@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/crypto-hash/-/crypto-hash-1.3.0.tgz#b402cb08f4529e9f4f09346c3e275942f845e247" + integrity sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg== + +debug@4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" + integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== + dependencies: + ms "2.1.2" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +deep-eql@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" + integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== + dependencies: + type-detect "^4.0.0" + +delay@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" + integrity sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw== + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^3.1.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ== + dependencies: + es6-promise "^4.0.3" + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eventemitter3@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +eventemitter3@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" + integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + +eyes@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" + integrity sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ== + +fast-stable-stringify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz#5c5543462b22aeeefd36d05b34e51c78cb86d313" + integrity sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag== + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.1, get-func-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isomorphic-ws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" + integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== + +jayson@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/jayson/-/jayson-4.1.3.tgz#db9be2e4287d9fef4fc05b5fe367abe792c2eee8" + integrity sha512-LtXh5aYZodBZ9Fc3j6f2w+MTNcnxteMOrb+QgIouguGOulWi0lieEkOUg+HkjjFs0DGoWDds6bi4E9hpNFLulQ== + dependencies: + "@types/connect" "^3.4.33" + "@types/node" "^12.12.54" + "@types/ws" "^7.4.4" + JSONStream "^1.3.5" + commander "^2.20.3" + delay "^5.0.0" + es6-promisify "^5.0.0" + eyes "^0.1.8" + isomorphic-ws "^4.0.1" + json-stringify-safe "^5.0.1" + uuid "^8.3.2" + ws "^7.5.10" + +js-sha256@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" + integrity sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA== + +js-yaml@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-stringify-safe@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + +json5@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== + dependencies: + minimist "^1.2.0" + +jsonparse@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +loupe@^2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" + integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + dependencies: + get-func-name "^2.0.1" + +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +minimatch@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4" + integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^3.0.4: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.0, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.1: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mocha@^9.0.3: + version "9.2.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9" + integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.3" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" + growl "1.10.5" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "4.2.1" + ms "2.1.3" + nanoid "3.3.1" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + which "2.0.2" + workerpool "6.2.0" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + +node-fetch@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +node-gyp-build@^4.3.0: + version "4.8.4" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" + integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +pako@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +prettier@^2.6.2: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +rpc-websockets@^9.0.2: + version "9.1.1" + resolved "https://registry.yarnpkg.com/rpc-websockets/-/rpc-websockets-9.1.1.tgz#5764336f3623ee1c5cc8653b7335183e3c0c78bd" + integrity sha512-1IXGM/TfPT6nfYMIXkJdzn+L4JEsmb0FL1O2OBjaH03V3yuUDdKFulGLMFG6ErV+8pZ5HVC0limve01RyO+saA== + dependencies: + "@swc/helpers" "^0.5.11" + "@types/uuid" "^8.3.4" + "@types/ws" "^8.2.2" + buffer "^6.0.3" + eventemitter3 "^5.0.1" + uuid "^8.3.2" + ws "^8.5.0" + optionalDependencies: + bufferutil "^4.0.1" + utf-8-validate "^5.0.2" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +serialize-javascript@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + +source-map-support@^0.5.6: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== + +strip-json-comments@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +superstruct@^0.15.4: + version "0.15.5" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.15.5.tgz#0f0a8d3ce31313f0d84c6096cd4fa1bfdedc9dab" + integrity sha512-4AOeU+P5UuE/4nOUkmcQdW5y7i9ndt1cQd/3iUe+LTz3RxESf/W/5lg4B74HbDMMv8PHnPnGCQFH45kBcrQYoQ== + +superstruct@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-2.0.2.tgz#3f6d32fbdc11c357deff127d591a39b996300c54" + integrity sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A== + +supports-color@8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +text-encoding-utf-8@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz#585b62197b0ae437e3c7b5d0af27ac1021e10d13" + integrity sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg== + +"through@>=2.2.7 <3": + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toml@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" + integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-mocha@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ts-mocha/-/ts-mocha-10.1.0.tgz#17a1c055f5f7733fd82447c4420740db87221bc8" + integrity sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA== + dependencies: + ts-node "7.0.1" + optionalDependencies: + tsconfig-paths "^3.5.0" + +ts-node@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-7.0.1.tgz#9562dc2d1e6d248d24bc55f773e3f614337d9baf" + integrity sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw== + dependencies: + arrify "^1.0.0" + buffer-from "^1.1.0" + diff "^3.1.0" + make-error "^1.1.1" + minimist "^1.2.0" + mkdirp "^0.5.1" + source-map-support "^0.5.6" + yn "^2.0.0" + +tsconfig-paths@^3.5.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.2" + minimist "^1.2.6" + strip-bom "^3.0.0" + +tslib@^2.0.3, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +type-detect@^4.0.0, type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +typescript@^5.7.3: + version "5.8.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" + integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== + +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + +utf-8-validate@^5.0.2: + version "5.0.10" + resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz#d7d10ea39318171ca982718b6b96a8d2442571a2" + integrity sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ== + dependencies: + node-gyp-build "^4.3.0" + +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +workerpool@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" + integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@^7.5.10: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +ws@^8.5.0: + version "8.18.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" + integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yn@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" + integrity sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== From ea05dd3490ff992350c5782b74f2acc49fff5433 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 21 Mar 2025 13:34:37 +0100 Subject: [PATCH 06/19] [Liquidity Mining] Obligation packing & unpacking (5) (#204) * optimized reserve (un)packing * obligation dynamically (un)packs * fixed BPF tests * using account discriminator --- .gitignore | 1 + token-lending/cli/src/main.rs | 1 - token-lending/program/src/processor.rs | 2 +- .../program/src/processor/liquidity_mining.rs | 20 +- .../program/tests/attributed_borrows.rs | 8 +- .../tests/borrow_obligation_liquidity.rs | 6 +- token-lending/program/tests/borrow_weight.rs | 8 +- .../tests/deposit_obligation_collateral.rs | 2 +- ...rve_liquidity_and_obligation_collateral.rs | 2 +- token-lending/program/tests/forgive_debt.rs | 3 +- token-lending/program/tests/helpers/mod.rs | 22 +- .../tests/helpers/solend_program_test.rs | 106 ++-- .../program/tests/init_lending_market.rs | 4 +- .../program/tests/init_obligation.rs | 7 +- token-lending/program/tests/init_reserve.rs | 15 +- .../program/tests/isolated_tier_assets.rs | 4 +- ...uidate_obligation_and_redeem_collateral.rs | 4 +- .../tests/mark_obligation_as_closeable.rs | 2 +- .../program/tests/obligation_end_to_end.rs | 4 +- .../program/tests/outflow_rate_limits.rs | 4 +- .../program/tests/refresh_obligation.rs | 34 +- .../program/tests/refresh_reserve.rs | 6 +- .../tests/repay_obligation_liquidity.rs | 2 +- .../tests/withdraw_obligation_collateral.rs | 4 +- ...ollateral_and_redeem_reserve_collateral.rs | 2 +- token-lending/sdk/src/error.rs | 6 + token-lending/sdk/src/state/lending_market.rs | 105 ++-- .../sdk/src/state/liquidity_mining.rs | 475 +++++++++++++----- token-lending/sdk/src/state/mod.rs | 68 ++- token-lending/sdk/src/state/obligation.rs | 210 ++++++-- token-lending/sdk/src/state/reserve.rs | 83 ++- 31 files changed, 909 insertions(+), 311 deletions(-) diff --git a/.gitignore b/.gitignore index 7a244d9bf0d..b7e8b8e693f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ hfuzz_workspace **/*.so **/.DS_Store test-ledger +cargo-test-*.profraw diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index 8c30b314b91..afa365e7325 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -781,7 +781,6 @@ fn main() { .required(true) .help("Reserve address"), ) - ) .subcommand( SubCommand::with_name("update-reserve") diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 4006289dc76..b458291b1b6 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -983,7 +983,7 @@ fn process_init_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro let token_program_id = next_account_info(account_info_iter)?; assert_rent_exempt(rent, obligation_info)?; - let mut obligation = assert_uninitialized::(obligation_info)?; + let mut obligation = Obligation::unpack_uninitialized(&obligation_info.data.borrow())?; if obligation_info.owner != program_id { msg!("Obligation provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 1c802e5bdfd..96d1b7a0849 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -7,9 +7,9 @@ //! implementation of the same feature. //! //! There are three admin-only ixs: -//! - [add_pool_reward] -//! - [cancel_pool_reward] -//! - [close_pool_reward] +//! - [add_pool_reward] (TODO: add bpf tests) +//! - [cancel_pool_reward] (TODO: add bpf tests) +//! - [close_pool_reward] (TODO: add bpf tests) //! //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 @@ -33,6 +33,7 @@ use solana_program::{ system_instruction, sysvar::Sysvar, }; +use solend_sdk::state::discriminator::AccountDiscriminator; use solend_sdk::{ error::LendingError, state::{LendingMarket, PositionKind, Reserve}, @@ -282,9 +283,16 @@ pub(crate) fn upgrade_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> // 3. // - // sanity checks pack and unpack reserves is ok - let reserve = Reserve::unpack(&accounts.reserve_info.data.borrow())?; - Reserve::pack(reserve, &mut accounts.reserve_info.data.borrow_mut())?; + // we upgrade discriminator as we've checked that the account is indeed + // a reserve account in [UpgradeReserveAccounts::from_unchecked_iter] + let mut data = accounts.reserve_info.data.borrow_mut(); + data[0] = AccountDiscriminator::Reserve as u8; + // Now the reserve can unpack fine and doesn't have to worry about + // migrations. + // Instead it returns an error on an invalid discriminator. + // This way a reserve cannot be mistaken for an obligation. + let reserve = Reserve::unpack(&data)?; + Reserve::pack(reserve, &mut data)?; Ok(()) } diff --git a/token-lending/program/tests/attributed_borrows.rs b/token-lending/program/tests/attributed_borrows.rs index 6d62ccfb657..39a7bafd28d 100644 --- a/token-lending/program/tests/attributed_borrows.rs +++ b/token-lending/program/tests/attributed_borrows.rs @@ -22,7 +22,7 @@ use solana_program::native_token::LAMPORTS_PER_SOL; use solend_sdk::math::Decimal; -use solend_program::state::{Obligation, ReserveConfig}; +use solend_program::state::ReserveConfig; use solend_sdk::state::ReserveFees; mod helpers; @@ -398,7 +398,7 @@ async fn test_calculations() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; // obligation 0 after borrowing 10 usd // usdc.borrow_attribution = 80 / 100 * 30 = 24 @@ -688,7 +688,7 @@ async fn test_withdraw() { Decimal::from_percent(250) ); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account.deposits[0].attributed_borrow_value, Decimal::from(7500u64) @@ -733,7 +733,7 @@ async fn test_withdraw() { Decimal::zero() ); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account.deposits[0].attributed_borrow_value, Decimal::from(10u64) diff --git a/token-lending/program/tests/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index 9f23189461b..881a7bbb90d 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -77,7 +77,7 @@ async fn setup( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -86,7 +86,7 @@ async fn setup( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let host_fee_receiver = User::new_with_balances(&mut test, &[(&wsol_mint::id(), 0)]).await; ( @@ -244,7 +244,7 @@ async fn test_success() { wsol_reserve_post, expected_wsol_reserve_post ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/borrow_weight.rs b/token-lending/program/tests/borrow_weight.rs index 1f8c306a052..9cbeadaedff 100644 --- a/token-lending/program/tests/borrow_weight.rs +++ b/token-lending/program/tests/borrow_weight.rs @@ -37,7 +37,7 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // obligation has borrowed 10 sol and sol = $10 but since borrow weight == 2, the // borrowed_value is 200 instead of 100. @@ -134,7 +134,7 @@ async fn test_borrow() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // - usdc ltv is 0.5, // - sol borrow weight is 2 // max you can borrow is 100 * 0.5 / 2 = 2.5 SOL @@ -176,7 +176,7 @@ async fn test_borrow() { test.advance_clock_by_slots(1).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; // max withdraw { @@ -302,7 +302,7 @@ async fn test_liquidation() { .await .unwrap(); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; // - usdc ltv is 0.5, // - sol borrow weight is 1 // max you can borrow is 100 * 0.5 = 5 SOL diff --git a/token-lending/program/tests/deposit_obligation_collateral.rs b/token-lending/program/tests/deposit_obligation_collateral.rs index c0879d9abe2..8992464dcbb 100644 --- a/token-lending/program/tests/deposit_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_obligation_collateral.rs @@ -80,7 +80,7 @@ async fn test_success() { let usdc_reserve_post = test.load_account(usdc_reserve.pubkey).await; assert_eq!(usdc_reserve, usdc_reserve_post); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs index 579d80b3d56..6b6779fc9b9 100644 --- a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs @@ -119,7 +119,7 @@ async fn test_success() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/forgive_debt.rs b/token-lending/program/tests/forgive_debt.rs index 63c5c0fb702..1d6360d301f 100644 --- a/token-lending/program/tests/forgive_debt.rs +++ b/token-lending/program/tests/forgive_debt.rs @@ -165,7 +165,7 @@ async fn test_forgive_debt_success_easy() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -182,6 +182,7 @@ async fn test_forgive_debt_success_easy() { allowed_borrow_value: Decimal::zero(), unhealthy_borrow_value: Decimal::zero(), super_unhealthy_borrow_value: Decimal::zero(), + user_reward_managers: obligation_post.account.user_reward_managers.clone(), ..obligations[0].account } ); diff --git a/token-lending/program/tests/helpers/mod.rs b/token-lending/program/tests/helpers/mod.rs index 0562b38d34f..9683c86ab74 100644 --- a/token-lending/program/tests/helpers/mod.rs +++ b/token-lending/program/tests/helpers/mod.rs @@ -117,25 +117,25 @@ pub mod bonk_mint { } pub trait AddPacked { + fn add_packed(&mut self, pubkey: Pubkey, amount: u64, data: &[u8], owner: &Pubkey); + fn add_packable_account( &mut self, pubkey: Pubkey, amount: u64, - data: &T, + unpacked: &T, owner: &Pubkey, - ); + ) { + let mut data = vec![0; T::get_packed_len()]; + unpacked.pack_into_slice(&mut data); + self.add_packed(pubkey, amount, &data, owner); + } } impl AddPacked for ProgramTest { - fn add_packable_account( - &mut self, - pubkey: Pubkey, - amount: u64, - data: &T, - owner: &Pubkey, - ) { - let mut account = Account::new(amount, T::get_packed_len(), owner); - data.pack_into_slice(&mut account.data); + fn add_packed(&mut self, pubkey: Pubkey, amount: u64, data: &[u8], owner: &Pubkey) { + let mut account = Account::new(amount, data.len(), owner); + account.data.copy_from_slice(data); self.add_account(pubkey, account); } } diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 05e1705d350..b7979450c98 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -57,6 +57,25 @@ use std::{ use super::mock_pyth::{init, set_price}; use super::mock_pyth_pull::{init as init_pull, set_price as set_price_pull}; +mod cu_budgets { + pub(super) const INIT_OBLIGATION: u32 = 5_001; + pub(super) const DEPOSIT_OBLIGATION_COLLATERAL: u32 = 70_002; + pub(super) const REFRESH_RESERVE: u32 = 2_000_003; + pub(super) const REFRESH_OBLIGATION: u32 = 1_000_004; + pub(super) const BORROW_OBLIGATION_LIQUIDITY: u32 = 140_005; + pub(super) const REPAY_OBLIGATION_LIQUIDITY: u32 = 70_006; + pub(super) const REDEEM_FEES: u32 = 80_007; + pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_008; + pub(super) const WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_009; + pub(super) const WITHDRAW_OBLIGATION_COLLATERAL: u32 = 100_010; + pub(super) const INIT_RESERVE: u32 = 90_011; + pub(super) const DEPOSIT: u32 = 70_012; + pub(super) const DONATE_TO_RESERVE: u32 = 50_013; + pub(super) const UPDATE_RESERVE_CONFIG: u32 = 25_014; + pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; + pub(super) const REDEEM: u32 = 130_016; +} + pub struct SolendProgramTest { pub context: ProgramTestContext, rent: Rent, @@ -254,6 +273,21 @@ impl SolendProgramTest { } } + pub async fn load_obligation(&mut self, acc_pk: Pubkey) -> Info { + let acc = self + .context + .banks_client + .get_account(acc_pk) + .await + .unwrap() + .unwrap(); + + Info { + pubkey: acc_pk, + account: Obligation::unpack(&acc.data).unwrap(), + } + } + pub async fn load_zeroable_account(&mut self, acc_pk: Pubkey) -> Info { let acc = self .context @@ -656,7 +690,7 @@ impl SolendProgramTest { let res = self .process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(80_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::INIT_RESERVE), init_reserve( solend_program::id(), liquidity_amount, @@ -842,7 +876,7 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::DEPOSIT), deposit_reserve_liquidity( solend_program::id(), liquidity_amount, @@ -870,7 +904,7 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::DONATE_TO_RESERVE), donate_to_reserve( solend_program::id(), liquidity_amount, @@ -904,7 +938,7 @@ impl Info { let oracle = oracle.unwrap_or(&default_oracle); let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(30_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::UPDATE_RESERVE_CONFIG), update_reserve_config( solend_program::id(), config, @@ -931,7 +965,9 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(70_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL, + ), deposit_reserve_liquidity_and_obligation_collateral( solend_program::id(), liquidity_amount, @@ -964,7 +1000,7 @@ impl Info { collateral_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(58_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REDEEM), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -998,12 +1034,12 @@ impl Info { user: &User, ) -> Result, BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(10_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::INIT_OBLIGATION), system_instruction::create_account( &test.context.payer.pubkey(), &obligation_keypair.pubkey(), - Rent::minimum_balance(&Rent::default(), Obligation::LEN), - Obligation::LEN as u64, + Rent::minimum_balance(&Rent::default(), Obligation::MIN_LEN), + Obligation::MIN_LEN as u64, &solend_program::id(), ), init_obligation( @@ -1018,9 +1054,7 @@ impl Info { .process_transaction(&instructions, Some(&[&obligation_keypair, &user.keypair])) .await { - Ok(()) => Ok(test - .load_account::(obligation_keypair.pubkey()) - .await), + Ok(()) => Ok(test.load_obligation(obligation_keypair.pubkey()).await), Err(e) => Err(e), } } @@ -1034,7 +1068,9 @@ impl Info { collateral_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(38_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::DEPOSIT_OBLIGATION_COLLATERAL, + ), deposit_obligation_collateral( solend_program::id(), collateral_amount, @@ -1060,7 +1096,7 @@ impl Info { ) -> Result<(), BanksClientError> { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(2_000_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REFRESH_RESERVE), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -1080,7 +1116,7 @@ impl Info { obligation: &Info, extra_reserve: Option<&Info>, ) -> Vec { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let reserve_pubkeys: Vec = { let mut r = HashSet::new(); r.extend( @@ -1159,7 +1195,9 @@ impl Info { Err(e) => return Err(e), }; - let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(1_000_000)]; + let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::REFRESH_OBLIGATION, + )]; instructions.push(refresh_reserve_instructions.last().unwrap().clone()); test.process_transaction(&instructions, None).await @@ -1174,14 +1212,16 @@ impl Info { host_fee_receiver_pubkey: Option, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let refresh_ixs = self .build_refresh_instructions(test, &obligation, Some(borrow_reserve)) .await; test.process_transaction(&refresh_ixs, None).await.unwrap(); - let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit(100_000)]; + let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::BORROW_OBLIGATION_LIQUIDITY, + )]; instructions.push(borrow_obligation_liquidity( solend_program::id(), liquidity_amount, @@ -1215,7 +1255,9 @@ impl Info { liquidity_amount: u64, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(35_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::REPAY_OBLIGATION_LIQUIDITY, + ), repay_obligation_liquidity( solend_program::id(), liquidity_amount, @@ -1239,7 +1281,7 @@ impl Info { reserve: &Info, ) -> Result<(), BanksClientError> { let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(50_000), + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::REDEEM_FEES), refresh_reserve( solend_program::id(), reserve.pubkey, @@ -1275,7 +1317,9 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(110_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL, + ), liquidate_obligation_and_redeem_reserve_collateral( solend_program::id(), liquidity_amount, @@ -1343,7 +1387,7 @@ impl Info { user: &User, collateral_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let refresh_ixs = self .build_refresh_instructions(test, &obligation, None) @@ -1352,7 +1396,9 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(110_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL, + ), withdraw_obligation_collateral_and_redeem_reserve_collateral( solend_program::id(), collateral_amount, @@ -1396,7 +1442,9 @@ impl Info { test.process_transaction( &[ - ComputeBudgetInstruction::set_compute_unit_limit(100_000), + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::WITHDRAW_OBLIGATION_COLLATERAL, + ), withdraw_obligation_collateral( solend_program::id(), collateral_amount, @@ -1451,7 +1499,7 @@ impl Info { reserve: &Info, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let mut instructions = self .build_refresh_instructions(test, &obligation, None) @@ -1815,7 +1863,7 @@ pub async fn scenario_1( .unwrap(); // borrow 10 SOL against 100k cUSDC. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -1840,7 +1888,7 @@ pub async fn scenario_1( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -1849,7 +1897,7 @@ pub async fn scenario_1( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, @@ -2021,7 +2069,7 @@ pub async fn custom_scenario( .await .unwrap(); - *obligation = test.load_account::(obligation.pubkey).await; + *obligation = test.load_obligation(obligation.pubkey).await; } // load accounts into reserve diff --git a/token-lending/program/tests/init_lending_market.rs b/token-lending/program/tests/init_lending_market.rs index 7549463dc9b..1443ccc01b3 100644 --- a/token-lending/program/tests/init_lending_market.rs +++ b/token-lending/program/tests/init_lending_market.rs @@ -12,7 +12,7 @@ use solana_sdk::signer::Signer; use solana_sdk::transaction::TransactionError; use solend_program::error::LendingError; use solend_program::instruction::init_lending_market; -use solend_program::state::{LendingMarket, RateLimiter, PROGRAM_VERSION}; +use solend_program::state::{discriminator::AccountDiscriminator, LendingMarket, RateLimiter}; #[tokio::test] async fn test_success() { @@ -28,7 +28,7 @@ async fn test_success() { assert_eq!( lending_market.account, LendingMarket { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::LendingMarket, bump_seed: lending_market.account.bump_seed, // TODO test this field owner: lending_market_owner.keypair.pubkey(), quote_currency: QUOTE_CURRENCY, diff --git a/token-lending/program/tests/init_obligation.rs b/token-lending/program/tests/init_obligation.rs index 943f5768d6a..1747113fa15 100644 --- a/token-lending/program/tests/init_obligation.rs +++ b/token-lending/program/tests/init_obligation.rs @@ -13,7 +13,9 @@ use solana_sdk::transaction::TransactionError; use solend_program::error::LendingError; use solend_program::instruction::init_obligation; use solend_program::math::Decimal; -use solend_program::state::{LastUpdate, LendingMarket, Obligation, PROGRAM_VERSION}; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, LendingMarket, Obligation, +}; async fn setup() -> (SolendProgramTest, Info, User) { let (test, lending_market, _, _, _, user) = @@ -34,7 +36,7 @@ async fn test_success() { assert_eq!( obligation.account, Obligation { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Obligation, last_update: LastUpdate { slot: 1000, stale: true @@ -52,6 +54,7 @@ async fn test_success() { super_unhealthy_borrow_value: Decimal::zero(), borrowing_isolated_asset: false, closeable: false, + user_reward_managers: Vec::new(), } ); } diff --git a/token-lending/program/tests/init_reserve.rs b/token-lending/program/tests/init_reserve.rs index a66f8d59209..62489668912 100644 --- a/token-lending/program/tests/init_reserve.rs +++ b/token-lending/program/tests/init_reserve.rs @@ -25,22 +25,19 @@ use solana_sdk::{ signature::{Keypair, Signer}, transaction::TransactionError, }; -use solend_program::state::LastUpdate; -use solend_program::state::RateLimiter; -use solend_program::state::Reserve; -use solend_program::state::ReserveCollateral; -use solend_program::state::ReserveLiquidity; -use solend_program::state::PROGRAM_VERSION; use solend_program::NULL_PUBKEY; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, LendingMarket, RateLimiter, Reserve, + ReserveCollateral, ReserveLiquidity, +}; use solend_program::{ error::LendingError, instruction::init_reserve, math::Decimal, state::{RateLimiterConfig, ReserveConfig, ReserveFees}, }; -use solend_sdk::state::LendingMarket; use spl_token::state::{Account as Token, Mint}; async fn setup() -> (SolendProgramTest, Info, User) { @@ -154,7 +151,7 @@ async fn test_success() { assert_eq!( wsol_reserve.account, Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1001, stale: true @@ -182,6 +179,8 @@ async fn test_success() { config: reserve_config, rate_limiter: RateLimiter::new(RateLimiterConfig::default(), 1001), attributed_borrow_value: Decimal::zero(), + borrows_pool_reward_manager: Default::default(), + deposits_pool_reward_manager: Default::default(), } ); } diff --git a/token-lending/program/tests/isolated_tier_assets.rs b/token-lending/program/tests/isolated_tier_assets.rs index e6615ce95cc..714e257fa6b 100644 --- a/token-lending/program/tests/isolated_tier_assets.rs +++ b/token-lending/program/tests/isolated_tier_assets.rs @@ -76,7 +76,7 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(!obligation.account.borrowing_isolated_asset); test.advance_clock_by_slots(1).await; @@ -104,7 +104,7 @@ async fn test_refresh_obligation() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account, diff --git a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs index 009fe817e8c..75eedf36ffa 100644 --- a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs +++ b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs @@ -205,7 +205,7 @@ async fn test_success_new() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -382,7 +382,7 @@ async fn test_success_insufficient_liquidity() { .await .unwrap(); - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, diff --git a/token-lending/program/tests/mark_obligation_as_closeable.rs b/token-lending/program/tests/mark_obligation_as_closeable.rs index 16b81c45ff5..c2c698f6750 100644 --- a/token-lending/program/tests/mark_obligation_as_closeable.rs +++ b/token-lending/program/tests/mark_obligation_as_closeable.rs @@ -129,7 +129,7 @@ async fn test_mark_obligation_as_closeable_success() { .await .unwrap(); - let obligation_post = test.load_account::(obligations[0].pubkey).await; + let obligation_post = test.load_obligation(obligations[0].pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/obligation_end_to_end.rs b/token-lending/program/tests/obligation_end_to_end.rs index d29bbe15b9e..575824a8e3d 100644 --- a/token-lending/program/tests/obligation_end_to_end.rs +++ b/token-lending/program/tests/obligation_end_to_end.rs @@ -71,7 +71,7 @@ async fn test_success() { .await .unwrap(); - let obligation = test.load_account(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -89,7 +89,7 @@ async fn test_success() { .await .unwrap(); - let obligation = test.load_account(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .withdraw_obligation_collateral_and_redeem_reserve_collateral( &mut test, diff --git a/token-lending/program/tests/outflow_rate_limits.rs b/token-lending/program/tests/outflow_rate_limits.rs index 35c953bf1de..f42dba03f28 100644 --- a/token-lending/program/tests/outflow_rate_limits.rs +++ b/token-lending/program/tests/outflow_rate_limits.rs @@ -78,7 +78,7 @@ async fn setup( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -87,7 +87,7 @@ async fn setup( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let host_fee_receiver = User::new_with_balances(&mut test, &[(&wsol_mint::id(), 0)]).await; ( diff --git a/token-lending/program/tests/refresh_obligation.rs b/token-lending/program/tests/refresh_obligation.rs index b16a0f3a519..6c3397a897f 100644 --- a/token-lending/program/tests/refresh_obligation.rs +++ b/token-lending/program/tests/refresh_obligation.rs @@ -12,7 +12,6 @@ use solend_program::instruction::refresh_obligation; use solend_program::processor::process_instruction; use solend_program::state::ObligationCollateral; -use solend_sdk::state::PROGRAM_VERSION; use std::collections::HashSet; use helpers::solend_program_test::{setup_world, BalanceChecker, Info, SolendProgramTest, User}; @@ -21,7 +20,10 @@ use solana_program::native_token::LAMPORTS_PER_SOL; use solana_program_test::*; use solana_sdk::signature::Keypair; use solend_program::state::SLOTS_PER_YEAR; -use solend_program::state::{LastUpdate, ObligationLiquidity, ReserveFees, ReserveLiquidity}; +use solend_program::state::{ + discriminator::AccountDiscriminator, LastUpdate, ObligationLiquidity, ReserveFees, + ReserveLiquidity, +}; use solend_program::{ math::{Decimal, TryAdd, TryDiv, TryMul}, @@ -101,7 +103,7 @@ async fn setup() -> ( .unwrap(); // borrow 6 SOL against 100k cUSDC. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -121,7 +123,7 @@ async fn setup() -> ( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -130,7 +132,7 @@ async fn setup() -> ( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, @@ -246,7 +248,7 @@ async fn test_success() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, @@ -415,7 +417,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; let max_reserve = reserves.iter().max_by_key(|r| r.pubkey).unwrap(); assert!(obligation.account.borrows[0].borrow_reserve == max_reserve.pubkey); @@ -441,7 +443,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(obligation.account.borrows[0].borrow_reserve == wsol_reserve.pubkey); lending_market @@ -466,7 +468,7 @@ async fn test_obligation_liquidity_ordering() { .await .unwrap(); - let obligation = test.load_account::(obligations[0].pubkey).await; + let obligation = test.load_obligation(obligations[0].pubkey).await; assert!(obligation.account.borrows[0].borrow_reserve == usdc_reserve.pubkey); } @@ -479,7 +481,7 @@ async fn test_normalize_obligation() { ); let reserve_1 = Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1, stale: false, @@ -496,7 +498,7 @@ async fn test_normalize_obligation() { ); let reserve_2 = Reserve { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: 1, stale: false, @@ -514,7 +516,7 @@ async fn test_normalize_obligation() { let obligation_pubkey = Pubkey::new_unique(); let obligation = Obligation { - version: PROGRAM_VERSION, + discriminator: AccountDiscriminator::Obligation, deposits: vec![ ObligationCollateral { deposit_reserve: reserve_1_pubkey, @@ -542,10 +544,12 @@ async fn test_normalize_obligation() { ..Obligation::default() }; - test.add_packable_account( + let mut packed_obligation = vec![0; obligation.size_in_bytes_when_packed()]; + obligation.pack_into_slice(&mut packed_obligation); + test.add_packed( obligation_pubkey, u32::MAX as u64, - &obligation, + &packed_obligation, &solend_program::id(), ); @@ -563,7 +567,7 @@ async fn test_normalize_obligation() { )]; test.process_transaction(&ix, None).await.unwrap(); - let o = test.load_account::(obligation_pubkey).await; + let o = test.load_obligation(obligation_pubkey).await; assert_eq!( o.account.deposits, vec![ObligationCollateral { diff --git a/token-lending/program/tests/refresh_reserve.rs b/token-lending/program/tests/refresh_reserve.rs index 7e84a2bf70b..a979fb7fe91 100644 --- a/token-lending/program/tests/refresh_reserve.rs +++ b/token-lending/program/tests/refresh_reserve.rs @@ -105,7 +105,7 @@ async fn setup() -> ( .unwrap(); // borrow 6 SOL against 100k cUSDC. All sol is borrowed, so the borrow rate should be at max. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .borrow_obligation_liquidity( &mut test, @@ -125,7 +125,7 @@ async fn setup() -> ( .unwrap(); // populate deposit value correctly. - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; lending_market .refresh_obligation(&mut test, &obligation) .await @@ -134,7 +134,7 @@ async fn setup() -> ( let lending_market = test.load_account(lending_market.pubkey).await; let usdc_reserve = test.load_account(usdc_reserve.pubkey).await; let wsol_reserve = test.load_account(wsol_reserve.pubkey).await; - let obligation = test.load_account::(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; ( test, diff --git a/token-lending/program/tests/repay_obligation_liquidity.rs b/token-lending/program/tests/repay_obligation_liquidity.rs index c3e9cd7a958..989da4fab21 100644 --- a/token-lending/program/tests/repay_obligation_liquidity.rs +++ b/token-lending/program/tests/repay_obligation_liquidity.rs @@ -90,7 +90,7 @@ async fn test_success() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/withdraw_obligation_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral.rs index 6cd535dec27..faf8073df32 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral.rs @@ -48,7 +48,7 @@ async fn test_success_withdraw_fixed_amount() { let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; assert_eq!(usdc_reserve_post.account, usdc_reserve.account); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { @@ -113,7 +113,7 @@ async fn test_success_withdraw_max() { let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; assert_eq!(usdc_reserve_post.account, usdc_reserve.account); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs index 8ce586d8b14..3cfc66f072f 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs @@ -130,7 +130,7 @@ async fn test_success() { } ); - let obligation_post = test.load_account::(obligation.pubkey).await; + let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, Obligation { diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index e5f6302199c..f3b050d7687 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -217,6 +217,12 @@ pub enum LendingError { /// Cannot close token account #[error("Cannot close token account")] CloseTokenAccountFailed, + /// Not an account discriminator + #[error("Given leading byte does not match any account discriminator")] + InvalidAccountDiscriminator, + /// Trying to use an account that hasn't been migrated + #[error("Trying to use an account that hasn't been migrated")] + AccountNotMigrated, } impl From for ProgramError { diff --git a/token-lending/sdk/src/state/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index 1836dc76e2d..ada34c4d3ca 100644 --- a/token-lending/sdk/src/state/lending_market.rs +++ b/token-lending/sdk/src/state/lending_market.rs @@ -1,3 +1,7 @@ +use std::convert::TryFrom; + +use crate::error::LendingError; + use super::*; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; use solana_program::{ @@ -10,8 +14,13 @@ use solana_program::{ /// Lending market state #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LendingMarket { - /// Version of lending market - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::LendingMarket]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Bump seed for derived authority address pub bump_seed: u8, /// Owner authority which can add new reserves @@ -43,7 +52,7 @@ impl LendingMarket { /// Initialize a lending market pub fn init(&mut self, params: InitLendingMarketParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::LendingMarket; self.bump_seed = params.bump_seed; self.owner = params.owner; self.quote_currency = params.quote_currency; @@ -76,7 +85,7 @@ pub struct InitLendingMarketParams { impl Sealed for LendingMarket {} impl IsInitialized for LendingMarket { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } @@ -88,7 +97,7 @@ impl Pack for LendingMarket { let output = array_mut_ref![output, 0, LENDING_MARKET_LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, bump_seed, owner, quote_currency, @@ -114,7 +123,7 @@ impl Pack for LendingMarket { 8 ]; - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *bump_seed = self.bump_seed.to_le_bytes(); owner.copy_from_slice(self.owner.as_ref()); quote_currency.copy_from_slice(self.quote_currency.as_ref()); @@ -138,7 +147,7 @@ impl Pack for LendingMarket { let input = array_ref![input, 0, LENDING_MARKET_LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, bump_seed, owner, quote_currency, @@ -164,15 +173,30 @@ impl Pack for LendingMarket { 8 ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Lending market version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::LendingMarket) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Lending market discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + Err(LendingError::AccountNotMigrated) => { + // We're migrating the account from v2.0.2 to v2.1.0. + // The reason this is safe to do is conveyed in these asserts: + debug_assert_eq!(Self::LEN, input.len()); + debug_assert!(Self::LEN < Reserve::LEN); + debug_assert!(Self::LEN < RESERVE_LEN_V2_0_2); + debug_assert!(Self::LEN < Obligation::MIN_LEN); + // Ie. there's no confusion with other account types. + + AccountDiscriminator::LendingMarket + } + Err(e) => return Err(e.into()), + }; let owner_pubkey = Pubkey::new_from_array(*owner); Ok(Self { - version, + discriminator, bump_seed: u8::from_le_bytes(*bump_seed), owner: owner_pubkey, quote_currency: *quote_currency, @@ -202,29 +226,50 @@ mod test { use super::*; use rand::Rng; + impl LendingMarket { + fn new_rand(rng: &mut impl Rng) -> Self { + Self { + discriminator: AccountDiscriminator::LendingMarket, + bump_seed: rng.gen(), + owner: Pubkey::new_unique(), + quote_currency: [rng.gen(); 32], + token_program_id: Pubkey::new_unique(), + oracle_program_id: Pubkey::new_unique(), + switchboard_oracle_program_id: Pubkey::new_unique(), + rate_limiter: rand_rate_limiter(), + whitelisted_liquidator: if rng.gen_bool(0.5) { + None + } else { + Some(Pubkey::new_unique()) + }, + risk_authority: Pubkey::new_unique(), + } + } + } + #[test] - fn pack_and_unpack_lending_market() { + fn pack_and_unpack_lending_market_v2_1_0() { let mut rng = rand::thread_rng(); - let lending_market = LendingMarket { - version: PROGRAM_VERSION, - bump_seed: rng.gen(), - owner: Pubkey::new_unique(), - quote_currency: [rng.gen(); 32], - token_program_id: Pubkey::new_unique(), - oracle_program_id: Pubkey::new_unique(), - switchboard_oracle_program_id: Pubkey::new_unique(), - rate_limiter: rand_rate_limiter(), - whitelisted_liquidator: if rng.gen_bool(0.5) { - None - } else { - Some(Pubkey::new_unique()) - }, - risk_authority: Pubkey::new_unique(), - }; + let lending_market = LendingMarket::new_rand(&mut rng); + + let mut packed = vec![0u8; LendingMarket::LEN]; + LendingMarket::pack(lending_market.clone(), &mut packed).unwrap(); + let unpacked = LendingMarket::unpack_from_slice(&packed).unwrap(); + assert_eq!(unpacked, lending_market); + } + + #[test] + fn pack_and_unpack_lending_market_v2_0_2() { + let mut rng = rand::thread_rng(); + let lending_market = LendingMarket::new_rand(&mut rng); let mut packed = vec![0u8; LendingMarket::LEN]; LendingMarket::pack(lending_market.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + let unpacked = LendingMarket::unpack_from_slice(&packed).unwrap(); + // upgraded assert_eq!(unpacked, lending_market); } } diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 5fdbfe07b74..fc59369ccb5 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -14,7 +14,7 @@ use solana_program::{ use super::pack_decimal; /// Determines the size of [PoolRewardManager] -const MAX_REWARDS: usize = 50; +pub const MAX_REWARDS: usize = 50; /// Cannot create a reward shorter than this. pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; @@ -61,6 +61,9 @@ pub enum PoolRewardSlot { Vacant { /// Increment this ID when adding new [PoolReward]. last_pool_reward_id: PoolRewardId, + /// An optimization to avoid writing data that has not changed. + /// When vacating a slot we set this to true. + has_been_vacated_in_this_tx: bool, }, /// Reward has not been closed yet. /// @@ -110,9 +113,10 @@ pub struct PoolReward { } /// Tracks user's LM rewards for a specific pool (reserve.) +#[derive(Debug, PartialEq, Eq, Default, Clone)] pub struct UserRewardManager { /// User cannot both borrow and deposit in the same reserve. - /// This manager is unique for this reserve within the [Obligation]. + /// This manager is unique for this reserve within an obligation. /// /// We know whether to use [crate::state::Reserve]'s /// `deposits_pool_reward_manager` or `borrows_pool_reward_manager` based on @@ -130,13 +134,25 @@ pub struct UserRewardManager { pub share: u64, /// Monotonically increasing time taken from clock sysvar. pub last_update_time_secs: u64, - /// The index of each reward is important. - /// It will match the index in the [PoolRewardManager] of the reserve. - pub rewards: Vec>, + /// The indices on [Self::rewards] are _not_ correlated with + /// [PoolRewardManager::pool_rewards]. + /// Instead, this vector only tracks meaningful rewards for the user. + /// See [UserReward::pool_reward_index]. + /// + /// This is a diversion from the Suilend implementation. + pub rewards: Vec, } /// Track user rewards for a specific [PoolReward]. +#[derive(Debug, PartialEq, Eq, Default, Clone)] pub struct UserReward { + /// Which [PoolReward] within the reserve's index does this [UserReward] + /// correspond to. + /// + /// # (Un)packing + /// There are ever only going to be at most [MAX_REWARDS]. + /// We therefore pack this value into a byte. + pub pool_reward_index: usize, /// Each pool reward gets an ID which is monotonically increasing with each /// new reward added to the pool. pub pool_reward_id: PoolRewardId, @@ -233,26 +249,56 @@ impl UserRewardManager { return Ok(()); } - self.rewards - .resize_with(pool_reward_manager.pool_rewards.len(), || None); - - for (reward_index, pool_reward) in pool_reward_manager.pool_rewards.iter_mut().enumerate() { + for (pool_reward_index, pool_reward) in + pool_reward_manager.pool_rewards.iter_mut().enumerate() + { let PoolRewardSlot::Occupied(pool_reward) = pool_reward else { // no reward to track continue; }; let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; + let has_ended = self.last_update_time_secs > end_time_secs; + + let maybe_user_reward = self + .rewards + .iter_mut() + .enumerate() + .find(|(_, r)| r.pool_reward_index == pool_reward_index); + + match maybe_user_reward { + Some((user_reward_index, user_reward)) + if has_ended && user_reward.earned_rewards == Decimal::zero() => + { + // Reward period ended and there's nothing to crank. + // We can clean up this user reward. + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist/ + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; + } + _ if has_ended => { + // reward period over & there are rewards yet to be cracked + } + Some((_, user_reward)) => { + // user is already accruing rewards, add the difference + + let new_reward_amount = pool_reward + .cumulative_rewards_per_share + .try_sub(user_reward.cumulative_rewards_per_share)? + .try_mul(Decimal::from(self.share))?; + + user_reward.earned_rewards = + user_reward.earned_rewards.try_add(new_reward_amount)?; - match self.rewards.get_mut(reward_index) { - None => unreachable!("We've just resized the rewards."), - Some(None) if self.last_update_time_secs > end_time_secs => { - // reward period ended, skip + user_reward.cumulative_rewards_per_share = + pool_reward.cumulative_rewards_per_share; } - Some(None) => { + None => { // user did not yet start accruing rewards let new_user_reward = UserReward { + pool_reward_index, pool_reward_id: pool_reward.id, cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, earned_rewards: if self.last_update_time_secs <= pool_reward.start_time_secs @@ -269,25 +315,9 @@ impl UserRewardManager { }, }; - // we resized this vector to match the pool rewards - self.rewards[reward_index] = Some(new_user_reward); - + self.rewards.push(new_user_reward); pool_reward.num_user_reward_managers += 1; } - Some(Some(user_reward)) => { - // user is already accruing rewards, add the difference - - let new_reward_amount = pool_reward - .cumulative_rewards_per_share - .try_sub(user_reward.cumulative_rewards_per_share)? - .try_mul(Decimal::from(self.share))?; - - user_reward.earned_rewards = - user_reward.earned_rewards.try_add(new_reward_amount)?; - - user_reward.cumulative_rewards_per_share = - pool_reward.cumulative_rewards_per_share; - } } } @@ -298,7 +328,16 @@ impl UserRewardManager { } impl PoolReward { - const LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES + 8 + 4 + 8 + 8 + 16; + const LEN: usize = Self::HEAD_LEN + Self::TAIL_LEN; + + const HEAD_LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES; + + /// - `start_time_secs`` + /// - `duration_secs`` + /// - `total_rewards`` + /// - `num_user_reward_managers`` + /// - `cumulative_rewards_per_share`` + const TAIL_LEN: usize = 8 + 4 + 8 + 8 + 16; } impl PoolRewardId { @@ -319,6 +358,9 @@ impl Default for PoolRewardSlot { fn default() -> Self { Self::Vacant { last_pool_reward_id: PoolRewardId(0), + // this is used for initialization of the pool reward manager so + // it makes sense as there are 0s in the account data already + has_been_vacated_in_this_tx: false, } } } @@ -340,70 +382,61 @@ impl Pack for PoolRewardManager { output[0..8].copy_from_slice(&self.total_shares.to_le_bytes()); output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes()); - for (index, pool_reward_slot) in self.pool_rewards.iter().enumerate() { + let rewards_to_pack = self + .pool_rewards + .iter() + .enumerate() + .filter(|(_, s)| s.should_be_packed()); + + for (index, pool_reward_slot) in rewards_to_pack { let offset = 16 + index * PoolReward::LEN; - let raw_pool_reward = array_mut_ref![output, offset, PoolReward::LEN]; - let ( - dst_id, - dst_vault, - dst_start_time_secs, - dst_duration_secs, - dst_total_rewards, - dst_num_user_reward_managers, - dst_cumulative_rewards_per_share_wads, - ) = mut_array_refs![ - raw_pool_reward, - PoolRewardId::LEN, - PUBKEY_BYTES, - 8, // start_time_secs - 4, // duration_secs - 8, // total_rewards - 8, // num_user_reward_managers - 16 // cumulative_rewards_per_share - ]; + let raw_pool_reward_head = array_mut_ref![output, offset, PoolReward::HEAD_LEN]; + let (dst_id, dst_vault) = + mut_array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; - let ( - id, - vault, - start_time_secs, - duration_secs, - total_rewards, - num_user_reward_managers, - cumulative_rewards_per_share, - ) = match pool_reward_slot { + match pool_reward_slot { PoolRewardSlot::Vacant { - last_pool_reward_id, - } => ( - *last_pool_reward_id, - Pubkey::default(), - 0u64, - 0u32, - 0u64, - 0u64, - Decimal::zero(), - ), - PoolRewardSlot::Occupied(pool_reward) => ( - pool_reward.id, - pool_reward.vault, - pool_reward.start_time_secs, - pool_reward.duration_secs, - pool_reward.total_rewards, - pool_reward.num_user_reward_managers, - pool_reward.cumulative_rewards_per_share, - ), + last_pool_reward_id: PoolRewardId(id), + .. + } => { + dst_id.copy_from_slice(&id.to_le_bytes()); + dst_vault.copy_from_slice(Pubkey::default().as_ref()); + } + PoolRewardSlot::Occupied(pool_reward) => { + dst_id.copy_from_slice(&pool_reward.id.0.to_le_bytes()); + dst_vault.copy_from_slice(pool_reward.vault.as_ref()); + + let raw_pool_reward_tail = + array_mut_ref![output, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + dst_start_time_secs, + dst_duration_secs, + dst_total_rewards, + dst_num_user_reward_managers, + dst_cumulative_rewards_per_share_wads, + ) = mut_array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + *dst_start_time_secs = pool_reward.start_time_secs.to_le_bytes(); + *dst_duration_secs = pool_reward.duration_secs.to_le_bytes(); + *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); + *dst_num_user_reward_managers = + pool_reward.num_user_reward_managers.to_le_bytes(); + // TBD: do we want to ceil? + pack_decimal( + pool_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share_wads, + ); + } }; - - dst_id.copy_from_slice(&id.0.to_le_bytes()); - dst_vault.copy_from_slice(vault.as_ref()); - *dst_start_time_secs = start_time_secs.to_le_bytes(); - *dst_duration_secs = duration_secs.to_le_bytes(); - *dst_total_rewards = total_rewards.to_le_bytes(); - *dst_num_user_reward_managers = num_user_reward_managers.to_le_bytes(); - pack_decimal( - cumulative_rewards_per_share, - dst_cumulative_rewards_per_share_wads, - ); } } @@ -417,36 +450,40 @@ impl Pack for PoolRewardManager { for index in 0..MAX_REWARDS { let offset = 8 + 8 + index * PoolReward::LEN; - let raw_pool_reward = array_ref![input, offset, PoolReward::LEN]; + let raw_pool_reward_head = array_ref![input, offset, PoolReward::HEAD_LEN]; - let ( - src_id, - src_vault, - src_start_time_secs, - src_duration_secs, - src_total_rewards, - src_num_user_reward_managers, - src_cumulative_rewards_per_share_wads, - ) = array_refs![ - raw_pool_reward, - PoolRewardId::LEN, - PUBKEY_BYTES, - 8, // start_time_secs - 4, // duration_secs - 8, // total_rewards - 8, // num_user_reward_managers - 16 // cumulative_rewards_per_share - ]; + let (src_id, src_vault) = + array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; - let vault = Pubkey::new_from_array(*src_vault); let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id)); + let vault = Pubkey::new_from_array(*src_vault); // SAFETY: ok to assign because we know the index is less than length pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { PoolRewardSlot::Vacant { last_pool_reward_id: pool_reward_id, + // nope, has been vacant since unpack + has_been_vacated_in_this_tx: false, } } else { + let raw_pool_reward_tail = + array_ref![input, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + src_start_time_secs, + src_duration_secs, + src_total_rewards, + src_num_user_reward_managers, + src_cumulative_rewards_per_share_wads, + ) = array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + PoolRewardSlot::Occupied(Box::new(PoolReward { id: pool_reward_id, vault, @@ -465,6 +502,148 @@ impl Pack for PoolRewardManager { } } +impl PoolRewardSlot { + /// If we know for sure that data hasn't changed then we can just skip packing. + fn should_be_packed(&self) -> bool { + let for_sure_has_not_changed = matches!( + self, + Self::Vacant { + has_been_vacated_in_this_tx: false, + .. + } + ); + + !for_sure_has_not_changed + } +} + +impl UserReward { + /// - [UserReward::pool_reward_index] truncated to a byte + /// - [PoolRewardId] + /// - packed [Decimal] + /// - packed [Decimal] + pub const LEN: usize = 1 + PoolRewardId::LEN + 16 + 16; +} + +impl UserRewardManager { + /// [Self] is dynamically sized based on how many [PoolReward]s are there + /// for the given [Self::reserve]. + /// + /// This is the maximum length a manager can have. + pub const MAX_LEN: usize = Self::HEAD_LEN + MAX_REWARDS * UserReward::LEN; + + /// Length of data before [Self::rewards] tail. + /// + /// - [Self::reserve] + /// - [Self::share] + /// - [Self::last_update_time_secs] + /// - [Self::rewards] vector length as u8 + const HEAD_LEN: usize = PUBKEY_BYTES + 8 + 8 + 1; + + /// How many bytes are needed to pack this [UserRewardManager]. + pub(crate) fn size_in_bytes_when_packed(&self) -> usize { + Self::HEAD_LEN + self.rewards.len() * UserReward::LEN + } + + /// Because [Self] is dynamically sized we don't implement [Pack] that + /// contains a misleading const `LEN`. + /// + /// We return how many bytes were written. + pub(crate) fn pack_into_slice(&self, output: &mut [u8]) { + let raw_user_reward_manager = array_mut_ref![output, 0, UserRewardManager::HEAD_LEN]; + + let (dst_reserve, dst_share, dst_last_update_time_secs, dst_user_rewards_len) = mut_array_refs![ + raw_user_reward_manager, + PUBKEY_BYTES, + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + dst_share.copy_from_slice(&self.share.to_le_bytes()); + dst_last_update_time_secs.copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + dst_reserve.copy_from_slice(self.reserve.as_ref()); + dst_user_rewards_len.copy_from_slice( + &({ + debug_assert!(MAX_REWARDS >= self.rewards.len()); + debug_assert!(u8::MAX >= MAX_REWARDS as _); + self.rewards.len() as u8 + }) + .to_le_bytes(), + ); + + for (index, user_reward) in self.rewards.iter().enumerate() { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_mut_ref![output, offset, UserReward::LEN]; + + let ( + dst_pool_reward_index, + dst_pool_reward_id, + dst_earned_rewards, + dst_cumulative_rewards_per_share, + ) = mut_array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + dst_pool_reward_id.copy_from_slice(&user_reward.pool_reward_id.0.to_le_bytes()); + pack_decimal(user_reward.earned_rewards, dst_earned_rewards); + pack_decimal( + user_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share, + ); + let pool_reward_index = { + assert!(user_reward.pool_reward_index < MAX_REWARDS); + assert!(MAX_REWARDS < u8::MAX as _); + // will always fit + user_reward.pool_reward_index as u8 + }; + dst_pool_reward_index.copy_from_slice(&pool_reward_index.to_le_bytes()); + } + } + + pub(crate) fn unpack_from_slice(input: &[u8]) -> Result { + let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; + + let (src_reserve, src_share, src_last_update_time_secs, src_user_rewards_len) = array_refs![ + raw_user_reward_manager_head, + PUBKEY_BYTES, + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + let reserve = Pubkey::new_from_array(*src_reserve); + let user_rewards_len = u8::from_le_bytes(*src_user_rewards_len) as _; + let share = u64::from_le_bytes(*src_share); + let last_update_time_secs = u64::from_le_bytes(*src_last_update_time_secs); + + let mut rewards = Vec::with_capacity(user_rewards_len); + for index in 0..user_rewards_len { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_ref![input, offset, UserReward::LEN]; + + let ( + src_pool_reward_index, + src_pool_reward_id, + src_earned_rewards, + src_cumulative_rewards_per_share, + ) = array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + rewards.push(UserReward { + pool_reward_index: u8::from_le_bytes(*src_pool_reward_index) as _, + pool_reward_id: PoolRewardId(u32::from_le_bytes(*src_pool_reward_id)), + earned_rewards: unpack_decimal(src_earned_rewards), + cumulative_rewards_per_share: unpack_decimal(src_cumulative_rewards_per_share), + }); + } + + Ok(Self { + reserve, + share, + last_update_time_secs, + rewards, + }) + } +} + #[cfg(test)] mod tests { //! TODO: Rewrite these tests from their Suilend counterparts. @@ -475,21 +654,60 @@ mod tests { use rand::Rng; fn pool_reward_manager_strategy() -> impl Strategy { - (0..100u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) + (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) + } + + fn user_reward_manager_strategy() -> impl Strategy { + (0..100u32).prop_perturb(|_, mut rng| UserRewardManager::new_rand(&mut rng)) } proptest! { #[test] - fn it_packs_and_unpacks(pool_reward_manager in pool_reward_manager_strategy()) { + fn it_packs_and_unpacks_pool_reward_manager(pool_reward_manager in pool_reward_manager_strategy()) { let mut packed = vec![0u8; PoolRewardManager::LEN]; Pack::pack_into_slice(&pool_reward_manager, &mut packed); let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); - prop_assert_eq!(pool_reward_manager, unpacked); + + prop_assert_eq!(pool_reward_manager.last_update_time_secs, unpacked.last_update_time_secs); + prop_assert_eq!(pool_reward_manager.total_shares, unpacked.total_shares); + + for (og, unpacked) in pool_reward_manager.pool_rewards.iter().zip(unpacked.pool_rewards.iter()) { + prop_assert_eq!(og, unpacked); + } + } + + #[test] + fn it_packs_and_unpacks_user_reward_manager(user_reward_manager in user_reward_manager_strategy()) { + let mut packed = vec![0u8; UserRewardManager::MAX_LEN]; + user_reward_manager.pack_into_slice(&mut packed); + let unpacked = UserRewardManager::unpack_from_slice(&packed).unwrap(); + prop_assert_eq!(user_reward_manager, unpacked); } } #[test] - fn it_unpacks_empty_bytes_as_default() { + fn it_packs_id_if_vacated_in_this_tx() { + let mut m = PoolRewardManager::default(); + m.pool_rewards[0] = PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_vacated_in_this_tx: true, + }; + + let mut packed = vec![0u8; PoolRewardManager::LEN]; + m.pack_into_slice(&mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + + assert_eq!( + unpacked.pool_rewards[0], + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_vacated_in_this_tx: false, + } + ); + } + + #[test] + fn it_unpacks_empty_pool_reward_manager_bytes_as_default() { let packed = vec![0u8; PoolRewardManager::LEN]; let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); assert_eq!(unpacked, PoolRewardManager::default()); @@ -499,7 +717,8 @@ mod tests { matches!( pool_reward, PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(0) + last_pool_reward_id: PoolRewardId(0), + has_been_vacated_in_this_tx: false, } ) }); @@ -556,7 +775,8 @@ mod tests { if is_vacant { PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(rng.gen()), + last_pool_reward_id: Default::default(), + has_been_vacated_in_this_tx: false, } } else { PoolRewardSlot::Occupied(Box::new(PoolReward { @@ -573,4 +793,25 @@ mod tests { } } } + + impl UserRewardManager { + pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { + let rewards_len = rng.gen_range(0..MAX_REWARDS); + Self { + reserve: Pubkey::new_unique(), + share: rng.gen(), + last_update_time_secs: rng.gen(), + rewards: std::iter::from_fn(|| { + Some(UserReward { + pool_reward_index: rng.gen_range(0..MAX_REWARDS), + pool_reward_id: PoolRewardId(rng.gen()), + earned_rewards: Decimal::from_scaled_val(rng.gen()), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + }) + }) + .take(rewards_len) + .collect(), + } + } + } } diff --git a/token-lending/sdk/src/state/mod.rs b/token-lending/sdk/src/state/mod.rs index c18c9793dfa..2e3dda4b3fc 100644 --- a/token-lending/sdk/src/state/mod.rs +++ b/token-lending/sdk/src/state/mod.rs @@ -17,6 +17,7 @@ pub use rate_limiter::*; pub use reserve::*; use crate::math::{Decimal, WAD}; +use discriminator::AccountDiscriminator; use solana_program::{msg, program_error::ProgramError}; /// Collateral tokens are initially valued at a ratio of 5:1 (collateral:liquidity) @@ -24,17 +25,70 @@ use solana_program::{msg, program_error::ProgramError}; pub const INITIAL_COLLATERAL_RATIO: u64 = 1; const INITIAL_COLLATERAL_RATE: u64 = INITIAL_COLLATERAL_RATIO * WAD; -/// Current version of the program and all new accounts created -pub const PROGRAM_VERSION: u8 = 1; - -/// Accounts are created with data zeroed out, so uninitialized state instances -/// will have the version set to 0. -pub const UNINITIALIZED_VERSION: u8 = 0; - /// Number of slots per year // 2 (slots per second) * 60 * 60 * 24 * 365 = 63072000 pub const SLOTS_PER_YEAR: u64 = 63072000; +/// Unmigrated accounts have this as their leading byte. +pub const PROGRAM_VERSION_2_0_2: u8 = 1; + +pub mod discriminator { + //! First 1 byte determines the account kind. + + use std::convert::TryFrom; + + use crate::error::LendingError; + + /// Match the first byte of an account data against this enum to determine + /// the account type. + /// + /// # Note + /// + /// In versions before @v2.1.0 this byte represented program version. + /// That's why we skip value `1u8`. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub enum AccountDiscriminator { + /// Account is not initialized yet. + #[default] + Uninitialized = 0, + /// [crate::state::LendingMarket] + LendingMarket = 2, + /// [crate::state::Reserve] + Reserve = 3, + /// [crate::state::Obligation] + Obligation = 4, + } + + impl TryFrom for AccountDiscriminator { + type Error = LendingError; + + fn try_from(value: u8) -> Result { + match value { + // the account data were just created and are filled with 0s + 0 => Ok(Self::Uninitialized), + + // we skip 1 because it was used for program version + 1 => Err(Self::Error::AccountNotMigrated), + + // valid accounts + 2 => Ok(Self::LendingMarket), + 3 => Ok(Self::Reserve), + 4 => Ok(Self::Obligation), + + _ => Err(Self::Error::InvalidAccountDiscriminator), + } + } + } + + impl TryFrom<&[u8; 1]> for AccountDiscriminator { + type Error = LendingError; + + fn try_from(value: &[u8; 1]) -> Result { + Self::try_from(value[0]) + } + } +} + // Helpers fn pack_decimal(decimal: Decimal, dst: &mut [u8; 16]) { *dst = decimal diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index d4bbe50e5e8..8696853932b 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -9,7 +9,7 @@ use solana_program::{ entrypoint::ProgramResult, msg, program_error::ProgramError, - program_pack::{IsInitialized, Pack, Sealed}, + program_pack::{IsInitialized, Sealed}, pubkey::{Pubkey, PUBKEY_BYTES}, }; use std::{ @@ -21,10 +21,22 @@ use std::{ pub const MAX_OBLIGATION_RESERVES: usize = 10; /// Lending market obligation state +/// +/// # (Un)packing +/// [Obligation] used to implement `Pack` in versions prior to 2.1.0. +/// Now [Obligation] is dynamically sized based on the reserves in +/// [Obligation::user_reward_managers]. +/// We manually implement packing and unpacking functions the the `Pack` trait +/// instead. #[derive(Clone, Debug, Default, PartialEq)] pub struct Obligation { - /// Version of the struct - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::Obligation]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Last update to collateral, liquidity, or their market values pub last_update: LastUpdate, /// Lending market address @@ -63,6 +75,12 @@ pub struct Obligation { pub borrowing_isolated_asset: bool, /// Obligation can be marked as closeable pub closeable: bool, + /// Collects liquidity mining rewards for positions (collateral/borrows). + /// + /// # (Un)packing + /// If there are no rewards to be collected then the obligation is packed + /// as if there was no liquidity mining feature involved. + pub user_reward_managers: Vec, } /// These are the two foundational user interactions in a borrow-lending protocol. @@ -84,7 +102,7 @@ impl Obligation { /// Initialize an obligation pub fn init(&mut self, params: InitObligationParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::Obligation; self.last_update = LastUpdate::new(params.current_slot); self.lending_market = params.lending_market; self.owner = params.owner; @@ -314,7 +332,7 @@ pub struct InitObligationParams { impl Sealed for Obligation {} impl IsInitialized for Obligation { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } @@ -426,15 +444,72 @@ const OBLIGATION_LIQUIDITY_LEN: usize = 112; // 32 + 16 + 16 + 16 + 32 /// This is the size of the account _before_ LM feature was added. const OBLIGATION_LEN_V1: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca -impl Pack for Obligation { - const LEN: usize = OBLIGATION_LEN_V1; +impl Obligation { + /// Obligation with no Liquidity Mining Rewards + pub const MIN_LEN: usize = OBLIGATION_LEN_V1; + + /// Maximum account size for obligation. + /// Scenario in which all reserves have all associated rewards filled. + /// + /// - [Self::user_reward_managers] vec length in u8 + /// - [Self::user_reward_managers] vector + const MAX_LEN: usize = Self::MIN_LEN + 1 + MAX_OBLIGATION_RESERVES * UserRewardManager::MAX_LEN; + + /// How many bytes are needed to pack this [UserRewardManager]. + pub fn size_in_bytes_when_packed(&self) -> usize { + let mut size = OBLIGATION_LEN_V1 + 1; + + for reward_manager in &self.user_reward_managers { + size += reward_manager.size_in_bytes_when_packed(); + } + + size + } + + /// Unpacks from slice but returns an error if the account is already + /// initialized. + pub fn unpack_uninitialized(input: &[u8]) -> Result { + let account = Self::unpack_unchecked(&input)?; + if account.is_initialized() { + Err(LendingError::AlreadyInitialized.into()) + } else { + Ok(account) + } + } + + /// Unpack from slice and check if initialized + pub fn unpack(input: &[u8]) -> Result { + let value = Self::unpack_unchecked(input)?; + if value.is_initialized() { + Ok(value) + } else { + Err(ProgramError::UninitializedAccount) + } + } + + /// Unpack from slice without checking if initialized + pub fn unpack_unchecked(input: &[u8]) -> Result { + if !(Self::MIN_LEN..=Self::MAX_LEN).contains(&input.len()) { + return Err(ProgramError::InvalidAccountData); + } + Self::unpack_from_slice(input) + } - // @v2.1.0 TODO: pack vec of user reward managers - fn pack_into_slice(&self, dst: &mut [u8]) { + /// Pack into slice + pub fn pack(src: Self, dst: &mut [u8]) -> Result<(), ProgramError> { + if !(Self::MIN_LEN..=Self::MAX_LEN).contains(&dst.len()) { + return Err(ProgramError::InvalidAccountData); + } + src.pack_into_slice(dst); + Ok(()) + } + + /// Since @v2.1.0 we pack vec of user reward managers + pub fn pack_into_slice(&self, dst: &mut [u8]) { let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -475,7 +550,7 @@ impl Pack for Obligation { ]; // obligation - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *last_update_slot = self.last_update.slot.to_le_bytes(); pack_bool(self.last_update.stale, last_update_stale); lending_market.copy_from_slice(self.lending_market.as_ref()); @@ -536,15 +611,37 @@ impl Pack for Obligation { pack_decimal(liquidity.market_value, market_value); offset += OBLIGATION_LIQUIDITY_LEN; } + + if !self.user_reward_managers.is_empty() { + // if the underlying buffer doesn't have enough space then we panic + + debug_assert!(MAX_OBLIGATION_RESERVES >= self.user_reward_managers.len()); + debug_assert!(u8::MAX > MAX_OBLIGATION_RESERVES as _); + let user_reward_managers_len = self.user_reward_managers.len() as u8; + dst[OBLIGATION_LEN_V1] = user_reward_managers_len; + + let mut offset = OBLIGATION_LEN_V1 + 1; + for user_reward_manager in self.user_reward_managers.iter() { + user_reward_manager.pack_into_slice(&mut dst[offset..]); + offset += user_reward_manager.size_in_bytes_when_packed(); + } + } else if dst.len() > OBLIGATION_LEN_V1 { + // set the length to 0 if obligation was resized before + + dst[OBLIGATION_LEN_V1] = 0; + }; + + // Any data after offset is garbage, but we don't zero it out bcs + // it costs CU and we'd have to do it bit by bit to avoid stack overflows. } /// Unpacks a byte buffer into an [Obligation]. - // @v2.1.0 TODO: unpack vector of optional user reward managers - fn unpack_from_slice(src: &[u8]) -> Result { + /// Since @v2.1.0 we unpack vector of user reward managers + pub fn unpack_from_slice(src: &[u8]) -> Result { let input = array_ref![src, 0, OBLIGATION_LEN_V1]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -584,11 +681,21 @@ impl Pack for Obligation { OBLIGATION_COLLATERAL_LEN + (OBLIGATION_LIQUIDITY_LEN * (MAX_OBLIGATION_RESERVES - 1)) ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Obligation version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::Obligation) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Obligation discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + Err(LendingError::AccountNotMigrated) => { + // We're migrating the account from v2.0.2 to v2.1.0. + debug_assert_eq!(OBLIGATION_LEN_V1, input.len()); + + AccountDiscriminator::Obligation + } + Err(e) => return Err(e.into()), + }; let deposits_len = u8::from_le_bytes(*deposits_len); let borrows_len = u8::from_le_bytes(*borrows_len); @@ -633,8 +740,24 @@ impl Pack for Obligation { offset += OBLIGATION_LIQUIDITY_LEN; } + let user_reward_managers = match src.get(OBLIGATION_LEN_V1) { + Some(len @ 1..) => { + let mut user_reward_managers = Vec::with_capacity(*len as _); + + let mut offset = OBLIGATION_LEN_V1 + 1; + for _ in 0..*len { + let user_reward_manager = UserRewardManager::unpack_from_slice(&src[offset..])?; + offset += user_reward_manager.size_in_bytes_when_packed(); + user_reward_managers.push(user_reward_manager); + } + + user_reward_managers + } + _ => Vec::new(), + }; + Ok(Self { - version, + discriminator, last_update: LastUpdate { slot: u64::from_le_bytes(*last_update_slot), stale: unpack_bool(last_update_stale)?, @@ -652,6 +775,7 @@ impl Pack for Obligation { super_unhealthy_borrow_value: unpack_decimal(super_unhealthy_borrow_value), borrowing_isolated_asset: unpack_bool(borrowing_isolated_asset)?, closeable: unpack_bool(closeable)?, + user_reward_managers, }) } } @@ -682,12 +806,10 @@ mod test { Decimal::from_scaled_val(rand::thread_rng().gen()) } - #[test] - fn pack_and_unpack_obligation() { - let mut rng = rand::thread_rng(); - for _ in 0..100 { - let obligation = Obligation { - version: PROGRAM_VERSION, + impl Obligation { + fn new_rand(rng: &mut impl Rng) -> Self { + Self { + discriminator: AccountDiscriminator::Obligation, last_update: LastUpdate { slot: rng.gen(), stale: rng.gen(), @@ -715,15 +837,47 @@ mod test { super_unhealthy_borrow_value: rand_decimal(), borrowing_isolated_asset: rng.gen(), closeable: rng.gen(), - }; + user_reward_managers: { + let user_reward_managers_len = rng.gen_range(0..=MAX_OBLIGATION_RESERVES); + + std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) + .take(user_reward_managers_len) + .collect() + }, + } + } + } - let mut packed = [0u8; OBLIGATION_LEN_V1]; + #[test] + fn pack_and_unpack_obligation_v2_1_0() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let obligation = Obligation::new_rand(&mut rng); + + let mut packed = [0u8; Obligation::MAX_LEN]; Obligation::pack(obligation.clone(), &mut packed).unwrap(); let unpacked = Obligation::unpack(&packed).unwrap(); assert_eq!(obligation, unpacked); } } + #[test] + fn pack_and_unpack_obligation_v2_0_2() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let obligation = Obligation::new_rand(&mut rng); + + let mut packed = [0u8; Obligation::MAX_LEN]; + Obligation::pack(obligation.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + + let unpacked = Obligation::unpack(&packed).unwrap(); + // upgraded + assert_eq!(obligation, unpacked); + } + } + #[test] fn obligation_accrue_interest_failure() { assert_eq!( diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index cea55c0e21f..c447e9b03a2 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -44,8 +44,13 @@ pub const MIN_SCALED_PRICE_OFFSET_BPS: i64 = -2000; /// Lending market reserve state #[derive(Clone, Debug, Default, PartialEq)] pub struct Reserve { - /// Version of the struct - pub version: u8, + /// For uninitialized accounts, this will be equal to [AccountDiscriminator::Uninitialized]. + /// Otherwise this is [AccountDiscriminator::Reserve]. + /// + /// # Note + /// For accounts last used with version prior to @v2.1.0 this will be equal + /// to [PROGRAM_VERSION_2_0_2]. + pub discriminator: AccountDiscriminator, /// Last slot when supply and rates updated pub last_update: LastUpdate, /// Lending market address @@ -86,7 +91,7 @@ impl Reserve { /// Initialize a reserve pub fn init(&mut self, params: InitReserveParams) { - self.version = PROGRAM_VERSION; + self.discriminator = AccountDiscriminator::Reserve; self.last_update = LastUpdate::new(params.current_slot); self.lending_market = params.lending_market; self.liquidity = params.liquidity; @@ -1238,7 +1243,7 @@ pub enum FeeCalculation { impl Sealed for Reserve {} impl IsInitialized for Reserve { fn is_initialized(&self) -> bool { - self.version != UNINITIALIZED_VERSION + !matches!(self.discriminator, AccountDiscriminator::Uninitialized) } } @@ -1252,12 +1257,11 @@ impl Pack for Reserve { // @TODO: break this up by reserve / liquidity / collateral / config https://git.io/JOCca // @v2.1.0: packs deposits_pool_reward_manager and borrows_pool_reward_manager - // @v2.1.0 TODO: add discriminator fn pack_into_slice(&self, output: &mut [u8]) { let output = array_mut_ref![output, 0, Reserve::LEN]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -1304,7 +1308,7 @@ impl Pack for Reserve { attributed_borrow_value, config_attributed_borrow_limit_open, config_attributed_borrow_limit_close, - _padding, // TODO: use some of this for discriminator + _padding, output_for_borrows_pool_reward_manager, output_for_deposits_pool_reward_manager, ) = mut_array_refs![ @@ -1362,7 +1366,7 @@ impl Pack for Reserve { ]; // reserve - *version = self.version.to_le_bytes(); + discriminator[0] = self.discriminator as _; *last_update_slot = self.last_update.slot.to_le_bytes(); pack_bool(self.last_update.stale, last_update_stale); lending_market.copy_from_slice(self.lending_market.as_ref()); @@ -1463,7 +1467,7 @@ impl Pack for Reserve { let input_v2_0_2 = array_ref![input, 0, RESERVE_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( - version, + discriminator, last_update_slot, last_update_stale, lending_market, @@ -1563,11 +1567,19 @@ impl Pack for Reserve { 49 ]; - let version = u8::from_le_bytes(*version); - if version > PROGRAM_VERSION { - msg!("Reserve version does not match lending program version"); - return Err(ProgramError::InvalidAccountData); - } + // Reserve migration v2.0.2 to v2.1.0 happens outside of the + // unpack method because there's no reliable way to ensure that we're + // migrating a reserve and not an obligation that's dynamically resized + // to the same length as a reserve. + let discriminator = match AccountDiscriminator::try_from(discriminator) { + Ok(d @ AccountDiscriminator::Uninitialized) => d, // yet to be set + Ok(d @ AccountDiscriminator::Reserve) => d, // migrated to v2.1.0 + Ok(_) => { + msg!("Reserve discriminator does not match"); + return Err(LendingError::InvalidAccountDiscriminator.into()); + } + Err(e) => return Err(e.into()), + }; let optimal_utilization_rate = u8::from_le_bytes(*config_optimal_utilization_rate); let max_borrow_rate = u8::from_le_bytes(*config_max_borrow_rate); @@ -1696,7 +1708,7 @@ impl Pack for Reserve { PoolRewardManager::unpack_to_box(input_for_deposits_pool_reward_manager)?; Ok(Self { - version, + discriminator, last_update, lending_market: Pubkey::new_from_array(*lending_market), liquidity, @@ -1724,10 +1736,8 @@ mod test { Decimal::from_scaled_val(rand::thread_rng().gen()) } - #[test] - fn pack_and_unpack_reserve() { - let mut rng = rand::thread_rng(); - for _ in 0..100 { + impl Reserve { + fn new_rand(rng: &mut impl Rng) -> Self { let optimal_utilization_rate = rng.gen(); let liquidation_bonus: u8 = rng.gen(); let liquidation_threshold: u8 = rng.gen(); @@ -1742,8 +1752,8 @@ mod test { None }; - let reserve = Reserve { - version: PROGRAM_VERSION, + Self { + discriminator: AccountDiscriminator::Reserve, last_update: LastUpdate { slot: rng.gen(), stale: rng.gen(), @@ -1799,9 +1809,17 @@ mod test { }, rate_limiter: rand_rate_limiter(), attributed_borrow_value: rand_decimal(), - borrows_pool_reward_manager: Box::new(PoolRewardManager::new_rand(&mut rng)), - deposits_pool_reward_manager: Box::new(PoolRewardManager::new_rand(&mut rng)), - }; + borrows_pool_reward_manager: Box::new(PoolRewardManager::new_rand(rng)), + deposits_pool_reward_manager: Box::new(PoolRewardManager::new_rand(rng)), + } + } + } + + #[test] + fn pack_and_unpack_reserve_v2_1_0() { + let mut rng = rand::thread_rng(); + for _ in 0..100 { + let reserve = Reserve::new_rand(&mut rng); let mut packed = [0u8; Reserve::LEN]; Reserve::pack(reserve.clone(), &mut packed).unwrap(); @@ -1810,6 +1828,23 @@ mod test { } } + #[test] + fn pack_and_unpack_reserve_v2_0_2() { + let mut rng = rand::thread_rng(); + let reserve = Reserve::new_rand(&mut rng); + + let mut packed = [0u8; Reserve::LEN]; + Reserve::pack(reserve.clone(), &mut packed).unwrap(); + // this is what version looked like before the upgrade to v2.1.0 + packed[0] = PROGRAM_VERSION_2_0_2; + + // reserve must be upgraded with a special ix + assert_eq!( + Reserve::unpack(&packed).unwrap_err(), + LendingError::AccountNotMigrated.into() + ); + } + const MAX_LIQUIDITY: u64 = u64::MAX / 5; fn utilizations() -> impl Strategy { From 17ac78400e0e48c7dc0e2ee90797468fa4a87bdc Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Thu, 27 Mar 2025 10:35:11 +0100 Subject: [PATCH 07/19] [Liquidity Mining] Adding claim ix & connecting all ixs to account logic (6) (#205) --- token-lending/program/src/processor.rs | 12 +- .../program/src/processor/liquidity_mining.rs | 799 ++---------------- .../liquidity_mining/add_pool_reward.rs | 266 ++++++ .../liquidity_mining/cancel_pool_reward.rs | 168 ++++ .../liquidity_mining/claim_user_reward.rs | 235 ++++++ .../liquidity_mining/close_pool_reward.rs | 200 +++++ .../liquidity_mining/upgrade_reserve.rs | 146 ++++ token-lending/sdk/src/error.rs | 3 + token-lending/sdk/src/instruction.rs | 33 +- token-lending/sdk/src/state/lending_market.rs | 1 + .../sdk/src/state/liquidity_mining.rs | 158 +++- token-lending/sdk/src/state/obligation.rs | 34 +- token-lending/sdk/src/state/reserve.rs | 1 + 13 files changed, 1289 insertions(+), 767 deletions(-) create mode 100644 token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs create mode 100644 token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs create mode 100644 token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs create mode 100644 token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs create mode 100644 token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index b458291b1b6..ac16c902381 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -211,7 +211,7 @@ pub fn process_instruction( token_amount, } => { msg!("Instruction: Add Pool Reward"); - liquidity_mining::process_add_pool_reward( + liquidity_mining::add_pool_reward::process( program_id, position_kind, start_time_secs, @@ -225,7 +225,7 @@ pub fn process_instruction( pool_reward_index, } => { msg!("Instruction: Cancel Pool Reward"); - liquidity_mining::process_cancel_pool_reward( + liquidity_mining::cancel_pool_reward::process( program_id, position_kind, pool_reward_index, @@ -237,18 +237,22 @@ pub fn process_instruction( pool_reward_index, } => { msg!("Instruction: Close Pool Reward"); - liquidity_mining::process_close_pool_reward( + liquidity_mining::close_pool_reward::process( program_id, position_kind, pool_reward_index, accounts, ) } + LendingInstruction::ClaimReward => { + msg!("Instruction: Claim Reward"); + liquidity_mining::claim_user_reward::process(program_id, accounts) + } // temporary ix for upgrade LendingInstruction::UpgradeReserveToV2_1_0 => { msg!("Instruction: Upgrade Reserve to v2.1.0"); - liquidity_mining::upgrade_reserve(program_id, accounts) + liquidity_mining::upgrade_reserve::process(program_id, accounts) } } } diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 96d1b7a0849..8a2ebf3f9b2 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -11,291 +11,27 @@ //! - [cancel_pool_reward] (TODO: add bpf tests) //! - [close_pool_reward] (TODO: add bpf tests) //! +//! There is an ix related to migration: +//! - [upgrade_reserve] (TODO: add bpf tests) +//! +//! There is one user ix: +//! - [claim_user_reward] (TODO: add bpf tests) +//! //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 -use crate::processor::{ - assert_rent_exempt, spl_token_close_account, spl_token_init_account, spl_token_transfer, - TokenCloseAccountParams, TokenInitializeAccountParams, TokenTransferParams, -}; -use add_pool_reward::{AddPoolRewardAccounts, AddPoolRewardParams}; -use cancel_pool_reward::{CancelPoolRewardAccounts, CancelPoolRewardParams}; -use close_pool_reward::{ClosePoolRewardAccounts, ClosePoolRewardParams}; +pub(crate) mod add_pool_reward; +pub(crate) mod cancel_pool_reward; +pub(crate) mod claim_user_reward; +pub(crate) mod close_pool_reward; +pub(crate) mod upgrade_reserve; + use solana_program::program_pack::Pack; -use solana_program::{ - account_info::{next_account_info, AccountInfo}, - clock::Clock, - entrypoint::ProgramResult, - msg, - program::invoke, - program_error::ProgramError, - pubkey::Pubkey, - rent::Rent, - system_instruction, - sysvar::Sysvar, -}; -use solend_sdk::state::discriminator::AccountDiscriminator; +use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; use solend_sdk::{ error::LendingError, - state::{LendingMarket, PositionKind, Reserve}, + state::{LendingMarket, Reserve}, }; use spl_token::state::Account as TokenAccount; -use std::convert::TryInto; -use upgrade_reserve::UpgradeReserveAccounts; - -/// # Accounts -/// -/// See [add_pool_reward::AddPoolRewardAccounts::from_unchecked_iter] for a list -/// of accounts and their constraints. -/// -/// # Effects -/// -/// 1. Initializes a new reward vault account and transfers -/// `reward_token_amount` tokens from the `reward_token_source` account to -/// the new reward vault account. -/// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. -/// 3. Packs all changes into account buffers. -pub(crate) fn process_add_pool_reward( - program_id: &Pubkey, - position_kind: PositionKind, - start_time_secs: u64, - end_time_secs: u64, - reward_token_amount: u64, - accounts: &[AccountInfo], -) -> ProgramResult { - let params = AddPoolRewardParams::new( - position_kind, - start_time_secs, - end_time_secs, - reward_token_amount, - )?; - - let accounts = - AddPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; - - // 1. - - spl_token_init_account(TokenInitializeAccountParams { - account: accounts.reward_token_vault_info.clone(), - mint: accounts.reward_mint_info.clone(), - owner: accounts.reward_authority_info.clone(), - rent: accounts.rent_info.clone(), - token_program: accounts.token_program_info.clone(), - })?; - let rent = &Rent::from_account_info(accounts.rent_info)?; - assert_rent_exempt(rent, accounts.reward_token_vault_info)?; - - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_source_info.clone(), - destination: accounts.reward_token_vault_info.clone(), - amount: params.reward_token_amount, - authority: accounts.lending_market_owner_info.clone(), - authority_signer_seeds: &[], - token_program: accounts.token_program_info.clone(), - })?; - - // 2. - - todo!("accounts.reserve.add_pool_reward(..)"); - - // 3. - - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - - Ok(()) -} - -/// # Accounts -/// -/// See [cancel_pool_reward::CancelPoolRewardAccounts::from_unchecked_iter] for a list -/// of accounts and their constraints. -/// -/// # Effects -/// -/// 1. Cancels any further reward emission, effectively setting end time to now. -/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. -/// 3. Packs all changes into account buffers. -pub(crate) fn process_cancel_pool_reward( - program_id: &Pubkey, - position_kind: PositionKind, - pool_reward_index: u64, - accounts: &[AccountInfo], -) -> ProgramResult { - let params = CancelPoolRewardParams::new(position_kind, pool_reward_index); - - let accounts = - CancelPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; - - // 1. - - let unallocated_rewards = todo!("accounts.reserve.cancel_pool_reward(..)"); - - // 2. - - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_vault_info.clone(), - destination: accounts.reward_token_destination_info.clone(), - amount: unallocated_rewards, - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, - ), - token_program: accounts.token_program_info.clone(), - })?; - - // 3. - - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - - Ok(()) -} - -/// # Accounts -/// -/// See [close_pool_reward::ClosePoolRewardAccounts::from_unchecked_iter] for a list -/// of accounts and their constraints. -/// -/// # Effects -/// -/// 1. Closes reward in the [Reserve] account if all users have claimed. -/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. -/// 3. Closes reward vault token account. -/// 3. Packs all changes into account buffers. -pub(crate) fn process_close_pool_reward( - program_id: &Pubkey, - position_kind: PositionKind, - pool_reward_index: u64, - accounts: &[AccountInfo], -) -> ProgramResult { - let params = ClosePoolRewardParams::new(position_kind, pool_reward_index); - - let accounts = - ClosePoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; - - // 1. - - let unallocated_rewards = todo!("accounts.reserve.close_pool_reward(..)"); - - // 2. - - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_vault_info.clone(), - destination: accounts.reward_token_destination_info.clone(), - amount: unallocated_rewards, - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, - ), - token_program: accounts.token_program_info.clone(), - })?; - - // 3. - - spl_token_close_account(TokenCloseAccountParams { - account: accounts.reward_token_vault_info.clone(), - destination: accounts.lending_market_owner_info.clone(), - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, - ), - token_program: accounts.token_program_info.clone(), - })?; - - // 4. - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - - Ok(()) -} - -/// Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. -/// Fails if reserve was not sized as @v2.0.2. -/// -/// Until this ix is called for a [Reserve] account, all other ixs that try to -/// unpack the [Reserve] will fail due to size mismatch. -/// -/// # Accounts -/// -/// See [upgrade_reserve::UpgradeReserveAccounts::from_unchecked_iter] for a list -/// of accounts and their constraints. -/// -/// # Effects -/// -/// 1. Takes payer's lamports and pays for the rent increase. -/// 2. Reallocates the reserve account to the latest size. -/// 3. Repacks the reserve account. -pub(crate) fn upgrade_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { - let accounts = UpgradeReserveAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; - - // - // 1. - // - - let current_rent = accounts.reserve_info.lamports(); - let new_rent = Rent::get()?.minimum_balance(Reserve::LEN); - - if let Some(extra_rent) = new_rent.checked_sub(current_rent) { - // some reserves have more rent than necessary, let's not assume that - // the payer always needs to add more rent - - invoke( - &system_instruction::transfer( - accounts.payer.key, - accounts.reserve_info.key, - extra_rent, - ), - &[ - accounts.payer.clone(), - accounts.reserve_info.clone(), - accounts.system_program.clone(), - ], - )?; - } - - // - // 2. - // - - // From the [AccountInfo::realloc] docs: - // - // > Memory used to grow is already zero-initialized upon program entrypoint - // > and re-zeroing it wastes compute units. If within the same call a program - // > reallocs from larger to smaller and back to larger again the new space - // > could contain stale data. Pass true for zero_init in this case, - // > otherwise compute units will be wasted re-zero-initializing. - let zero_init = false; - accounts.reserve_info.realloc(Reserve::LEN, zero_init)?; - - // - // 3. - // - - // we upgrade discriminator as we've checked that the account is indeed - // a reserve account in [UpgradeReserveAccounts::from_unchecked_iter] - let mut data = accounts.reserve_info.data.borrow_mut(); - data[0] = AccountDiscriminator::Reserve as u8; - // Now the reserve can unpack fine and doesn't have to worry about - // migrations. - // Instead it returns an error on an invalid discriminator. - // This way a reserve cannot be mistaken for an obligation. - let reserve = Reserve::unpack(&data)?; - Reserve::pack(reserve, &mut data)?; - - Ok(()) -} /// Unpacks a spl_token [TokenAccount]. fn unpack_token_account(data: &[u8]) -> Result { @@ -303,6 +39,8 @@ fn unpack_token_account(data: &[u8]) -> Result { } /// Derives the reward vault authority PDA address. +/// +/// TODO: Accept a bump seed to avoid recalculating it. fn reward_vault_authority( program_id: &Pubkey, lending_market_key: &Pubkey, @@ -328,466 +66,41 @@ fn reward_vault_authority_seeds<'keys>( ] } -mod add_pool_reward { - use solend_sdk::state::MIN_REWARD_PERIOD_SECS; - - use super::*; - - /// Use [Self::new] to validate the parameters. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub(super) struct AddPoolRewardParams { - pub(super) position_kind: PositionKind, - /// At least the current timestamp. - pub(super) start_time_secs: u64, - /// Larger than [MIN_REWARD_PERIOD_SECS]. - pub(super) duration_secs: u32, - /// Larger than zero. - pub(super) reward_token_amount: u64, - - _priv: (), - } - - /// Use [Self::from_unchecked_iter] to validate the accounts except for - /// * `reward_token_vault_info` - /// * `rent_info` - pub(super) struct AddPoolRewardAccounts<'a, 'info> { - /// ✅ belongs to this program - /// ✅ unpacks - /// ✅ belongs to `lending_market_info` - pub(super) reserve_info: &'a AccountInfo<'info>, - pub(super) reward_mint_info: &'a AccountInfo<'info>, - /// ✅ belongs to the token program - /// ✅ owned by `lending_market_owner_info` - /// ✅ has enough tokens - /// ✅ matches `reward_mint_info` - pub(super) reward_token_source_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` - pub(super) reward_authority_info: &'a AccountInfo<'info>, - /// ✅ belongs to the token program - /// ✅ has no data - /// ❓ we don't yet know whether it's rent exempt - pub(super) reward_token_vault_info: &'a AccountInfo<'info>, - /// ✅ belongs to this program - /// ✅ unpacks - pub(super) lending_market_info: &'a AccountInfo<'info>, - /// ✅ is a signer - /// ✅ matches `lending_market_info` - /// TBD: do we want to create another signer authority to be able to - /// delegate reward management to a softer multisig? - pub(super) lending_market_owner_info: &'a AccountInfo<'info>, - /// ❓ we don't yet whether this is rent info - pub(super) rent_info: &'a AccountInfo<'info>, - /// ✅ matches `lending_market_info` - pub(super) token_program_info: &'a AccountInfo<'info>, - - pub(super) reserve: Box, - - _priv: (), - } - - impl AddPoolRewardParams { - pub(super) fn new( - position_kind: PositionKind, - start_time_secs: u64, - end_time_secs: u64, - reward_token_amount: u64, - ) -> Result { - let clock = &Clock::get()?; - - let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); - - if start_time_secs <= end_time_secs { - msg!("Pool reward must end after it starts"); - return Err(LendingError::MathOverflow.into()); - } - - let duration_secs: u32 = { - // SAFETY: just checked that start time is strictly smaller - let d = end_time_secs - start_time_secs; - d.try_into().map_err(|_| { - msg!("Pool reward duration is too long"); - LendingError::MathOverflow - })? - }; - if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { - msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); - return Err(LendingError::PoolRewardPeriodTooShort.into()); - } - - if reward_token_amount == 0 { - msg!("Pool reward amount must be greater than zero"); - return Err(LendingError::InvalidAmount.into()); - } - - Ok(Self { - position_kind, - start_time_secs, - duration_secs, - reward_token_amount, - - _priv: (), - }) - } - } - - impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - params: &AddPoolRewardParams, - iter: &mut impl Iterator>, - ) -> Result, ProgramError> { - let reserve_info = next_account_info(iter)?; - let reward_mint_info = next_account_info(iter)?; - let reward_token_source_info = next_account_info(iter)?; - let reward_authority_info = next_account_info(iter)?; - let reward_token_vault_info = next_account_info(iter)?; - let lending_market_info = next_account_info(iter)?; - let lending_market_owner_info = next_account_info(iter)?; - let rent_info = next_account_info(iter)?; - let token_program_info = next_account_info(iter)?; - - let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( - program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, - lending_market_owner_info, - token_program_info, - )?; - - if reward_token_source_info.owner != token_program_info.key { - msg!("Reward token source provided must be owned by the token program"); - return Err(LendingError::InvalidTokenOwner.into()); - } - let reward_token_source = - unpack_token_account(&reward_token_source_info.data.borrow())?; - if reward_token_source.owner != *lending_market_owner_info.key { - msg!("Reward token source owner does not match the lending market owner provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - if reward_token_source.amount >= params.reward_token_amount { - msg!("Reward token source is empty"); - return Err(LendingError::InvalidAccountInput.into()); - } - if reward_token_source.mint != *reward_mint_info.key { - msg!("Reward token source mint does not match the reward mint provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - - if reward_token_vault_info.owner != token_program_info.key { - msg!("Reward token vault provided must be owned by the token program"); - return Err(LendingError::InvalidTokenOwner.into()); - } - if !reward_token_vault_info.data.borrow().is_empty() { - msg!("Reward token vault provided must be empty"); - return Err(LendingError::InvalidAccountInput.into()); - } - - Ok(Self { - reserve_info, - reward_mint_info, - reward_token_source_info, - reward_authority_info, - reward_token_vault_info, - lending_market_info, - lending_market_owner_info, - rent_info, - token_program_info, - - reserve, - - _priv: (), - }) - } - } -} - -mod cancel_pool_reward { - use super::*; - - pub(super) struct CancelPoolRewardParams { - position_kind: PositionKind, - pool_reward_index: u64, - - _priv: (), - } - - /// Use [Self::from_unchecked_iter] to validate the accounts. - pub(super) struct CancelPoolRewardAccounts<'a, 'info> { - /// ✅ belongs to this program - /// ✅ unpacks - /// ✅ belongs to `lending_market_info` - pub(super) reserve_info: &'a AccountInfo<'info>, - pub(super) reward_mint_info: &'a AccountInfo<'info>, - /// ✅ belongs to the token program - /// ✅ owned by `lending_market_owner_info` - /// ✅ matches `reward_mint_info` - pub(super) reward_token_destination_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` - pub(super) reward_authority_info: &'a AccountInfo<'info>, - /// ✅ matches reward vault pubkey stored in the [Reserve] - pub(super) reward_token_vault_info: &'a AccountInfo<'info>, - /// ✅ belongs to this program - /// ✅ unpacks - pub(super) lending_market_info: &'a AccountInfo<'info>, - /// ✅ is a signer - /// ✅ matches `lending_market_info` - pub(super) lending_market_owner_info: &'a AccountInfo<'info>, - /// ✅ matches `lending_market_info` - pub(super) token_program_info: &'a AccountInfo<'info>, - - pub(super) reserve: Box, - - _priv: (), - } - - impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - params: &CancelPoolRewardParams, - iter: &mut impl Iterator>, - ) -> Result, ProgramError> { - let reserve_info = next_account_info(iter)?; - let reward_mint_info = next_account_info(iter)?; - let reward_token_destination_info = next_account_info(iter)?; - let reward_authority_info = next_account_info(iter)?; - let reward_token_vault_info = next_account_info(iter)?; - let lending_market_info = next_account_info(iter)?; - let lending_market_owner_info = next_account_info(iter)?; - let token_program_info = next_account_info(iter)?; - - let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( - program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, - lending_market_owner_info, - token_program_info, - )?; - - todo!("Check that reward_token_vault_info matches reward vault pubkey stored in [Reserve]"); - - if reward_token_destination_info.owner != token_program_info.key { - msg!("Reward token destination provided must be owned by the token program"); - return Err(LendingError::InvalidTokenOwner.into()); - } - let reward_token_destination = - unpack_token_account(&reward_token_destination_info.data.borrow())?; - if reward_token_destination.owner != *lending_market_owner_info.key { - // TBD: superfluous check? - msg!("Reward token destination owner does not match the lending market owner provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - if reward_token_destination.mint != *reward_mint_info.key { - msg!("Reward token destination mint does not match the reward mint provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - - Ok(Self { - _priv: (), - - reserve_info, - reward_mint_info, - reward_token_destination_info, - reward_authority_info, - reward_token_vault_info, - lending_market_info, - lending_market_owner_info, - token_program_info, - - reserve, - }) - } - } - - impl CancelPoolRewardParams { - pub(super) fn new(position_kind: PositionKind, pool_reward_index: u64) -> Self { - Self { - position_kind, - pool_reward_index, - - _priv: (), - } - } - } -} - -mod close_pool_reward { - use super::*; - - pub(super) struct ClosePoolRewardParams { - position_kind: PositionKind, - pool_reward_index: u64, - - _priv: (), - } - - /// Use [Self::from_unchecked_iter] to validate the accounts. - pub(super) struct ClosePoolRewardAccounts<'a, 'info> { - _priv: (), - - /// ✅ belongs to this program - /// ✅ unpacks - /// ✅ belongs to `lending_market_info` - pub(super) reserve_info: &'a AccountInfo<'info>, - pub(super) reward_mint_info: &'a AccountInfo<'info>, - /// ✅ belongs to the token program - /// ✅ owned by `lending_market_owner_info` - /// ✅ matches `reward_mint_info` - pub(super) reward_token_destination_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` - pub(super) reward_authority_info: &'a AccountInfo<'info>, - /// ✅ matches reward vault pubkey stored in the [Reserve] - pub(super) reward_token_vault_info: &'a AccountInfo<'info>, - /// ✅ belongs to this program - /// ✅ unpacks - pub(super) lending_market_info: &'a AccountInfo<'info>, - /// ✅ is a signer - /// ✅ matches `lending_market_info` - pub(super) lending_market_owner_info: &'a AccountInfo<'info>, - /// ✅ matches `lending_market_info` - pub(super) token_program_info: &'a AccountInfo<'info>, - - pub(super) reserve: Box, - } - - impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - params: &ClosePoolRewardParams, - iter: &mut impl Iterator>, - ) -> Result, ProgramError> { - let reserve_info = next_account_info(iter)?; - let reward_mint_info = next_account_info(iter)?; - let reward_token_destination_info = next_account_info(iter)?; - let reward_authority_info = next_account_info(iter)?; - let reward_token_vault_info = next_account_info(iter)?; - let lending_market_info = next_account_info(iter)?; - let lending_market_owner_info = next_account_info(iter)?; - let token_program_info = next_account_info(iter)?; - - let reserve = check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve( - program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, - lending_market_owner_info, - token_program_info, - )?; - - todo!("Check that reward_token_vault_info matches reward vault pubkey stored in [Reserve]"); - - if reward_token_destination_info.owner != token_program_info.key { - msg!("Reward token destination provided must be owned by the token program"); - return Err(LendingError::InvalidTokenOwner.into()); - } - let reward_token_destination = - unpack_token_account(&reward_token_destination_info.data.borrow())?; - if reward_token_destination.owner != *lending_market_owner_info.key { - // TBD: superfluous check? - msg!("Reward token destination owner does not match the lending market owner provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - if reward_token_destination.mint != *reward_mint_info.key { - msg!("Reward token destination mint does not match the reward mint provided"); - return Err(LendingError::InvalidAccountInput.into()); - } - - Ok(Self { - reserve_info, - reward_mint_info, - reward_token_destination_info, - reward_authority_info, - reward_token_vault_info, - lending_market_info, - lending_market_owner_info, - token_program_info, - - reserve, - - _priv: (), - }) - } - } - - impl ClosePoolRewardParams { - pub(super) fn new(position_kind: PositionKind, pool_reward_index: u64) -> Self { - Self { - position_kind, - pool_reward_index, +/// Does all the checks of [check_and_unpack_pool_reward_accounts] and additionally: +/// +/// * ✅ `lending_market_owner_info` is a signer +/// * ✅ `lending_market_owner_info` matches `lending_market_info` +fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'info>( + program_id: &Pubkey, + reserve_info: &AccountInfo<'info>, + reward_mint_info: &AccountInfo<'info>, + reward_authority_info: &AccountInfo<'info>, + lending_market_info: &AccountInfo<'info>, + lending_market_owner_info: &AccountInfo<'info>, + token_program_info: &AccountInfo<'info>, +) -> Result<(LendingMarket, Box), ProgramError> { + let (lending_market, reserve) = check_and_unpack_pool_reward_accounts( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + )?; - _priv: (), - } - } + if lending_market.owner != *lending_market_owner_info.key { + msg!("Lending market owner does not match the lending market owner provided"); + return Err(LendingError::InvalidMarketOwner.into()); } -} - -mod upgrade_reserve { - use solend_sdk::state::RESERVE_LEN_V2_0_2; - - use super::*; - - pub(super) struct UpgradeReserveAccounts<'a, 'info> { - /// Reserve sized as v2.0.2. - /// - /// ✅ belongs to this program - /// ✅ is sized [RESERVE_LEN_V2_0_2], ie. for sure [Reserve] account - pub(super) reserve_info: &'a AccountInfo<'info>, - /// The pool fella who pays for this. - /// - /// ✅ is a signer - pub(super) payer: &'a AccountInfo<'info>, - /// The system program. - /// - /// ✅ is the system program - pub(super) system_program: &'a AccountInfo<'info>, - - _priv: (), + if !lending_market_owner_info.is_signer { + msg!("Lending market owner provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); } - impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - iter: &mut impl Iterator>, - ) -> Result, ProgramError> { - let reserve_info = next_account_info(iter)?; - let payer = next_account_info(iter)?; - let system_program = next_account_info(iter)?; - - if !payer.is_signer { - msg!("Payer provided must be a signer"); - return Err(LendingError::InvalidSigner.into()); - } - - if reserve_info.owner != program_id { - msg!("Reserve provided must be owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } - - if reserve_info.data_len() != RESERVE_LEN_V2_0_2 { - msg!("Reserve provided must be sized as v2.0.2"); - return Err(LendingError::InvalidAccountInput.into()); - } - - if system_program.key != &solana_program::system_program::id() { - msg!("System program provided must be the system program"); - return Err(LendingError::InvalidAccountInput.into()); - } - - Ok(Self { - payer, - reserve_info, - system_program, - _priv: (), - }) - } - } + Ok((lending_market, reserve)) } -/// Common checks within the admin ixs are: +/// Checks that: /// /// * ✅ `reserve_info` belongs to this program /// * ✅ `reserve_info` unpacks @@ -795,20 +108,16 @@ mod upgrade_reserve { /// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` /// * ✅ `lending_market_info` belongs to this program /// * ✅ `lending_market_info` unpacks -/// * ✅ `lending_market_owner_info` is a signer -/// * ✅ `lending_market_owner_info` matches `lending_market_info` /// * ✅ `token_program_info` matches `lending_market_info` -/// -/// To avoid unpacking reserve twice we return it. -fn check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve<'info>( +/// * ✅ `reward_mint_info` belongs to the token program +fn check_and_unpack_pool_reward_accounts<'info>( program_id: &Pubkey, reserve_info: &AccountInfo<'info>, reward_mint_info: &AccountInfo<'info>, reward_authority_info: &AccountInfo<'info>, lending_market_info: &AccountInfo<'info>, - lending_market_owner_info: &AccountInfo<'info>, token_program_info: &AccountInfo<'info>, -) -> Result, ProgramError> { +) -> Result<(LendingMarket, Box), ProgramError> { if reserve_info.owner != program_id { msg!("Reserve provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); @@ -831,13 +140,9 @@ fn check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve<'info>( return Err(LendingError::InvalidTokenProgram.into()); } - if lending_market.owner != *lending_market_owner_info.key { - msg!("Lending market owner does not match the lending market owner provided"); - return Err(LendingError::InvalidMarketOwner.into()); - } - if !lending_market_owner_info.is_signer { - msg!("Lending market owner provided must be a signer"); - return Err(LendingError::InvalidSigner.into()); + if reward_mint_info.owner != token_program_info.key { + msg!("Reward mint provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); } let (expected_reward_vault_authority, _bump_seed) = reward_vault_authority( @@ -851,5 +156,5 @@ fn check_pool_reward_accounts_for_admin_ixs_and_unpack_reserve<'info>( return Err(LendingError::InvalidAccountInput.into()); } - Ok(reserve) + Ok((lending_market, reserve)) } diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs new file mode 100644 index 00000000000..540815a3b51 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -0,0 +1,266 @@ +//! Adds a new pool reward to a reserve. +//! +//! Each pool reward has a unique vault that holds the reward tokens. + +use crate::processor::{ + assert_rent_exempt, spl_token_init_account, spl_token_transfer, TokenInitializeAccountParams, + TokenTransferParams, +}; +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + sysvar::Sysvar, +}; +use solend_sdk::state::MIN_REWARD_PERIOD_SECS; +use solend_sdk::{ + error::LendingError, + state::{PositionKind, Reserve}, +}; +use std::convert::TryInto; + +use super::{check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account}; + +/// Use [Self::new] to validate the parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AddPoolRewardParams { + position_kind: PositionKind, + /// At least the current timestamp. + start_time_secs: u64, + /// Larger than [MIN_REWARD_PERIOD_SECS]. + duration_secs: u32, + /// Larger than zero. + reward_token_amount: u64, +} + +/// Use [Self::from_unchecked_iter] to validate the accounts except for +/// * `reward_token_vault_info` +/// * `rent_info` +struct AddPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ has enough tokens + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_source_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ has no data + /// ✅ is writable + /// ❓ we don't yet know whether it's rent exempt + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + _lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + /// TBD: do we want to create another signer authority to be able to + /// delegate reward management to a softer multisig? + lending_market_owner_info: &'a AccountInfo<'info>, + /// ❓ we don't yet whether this is rent info + rent_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: Box, +} + +/// # Effects +/// +/// 1. Initializes a new reward vault account and transfers +/// `reward_token_amount` tokens from the `reward_token_source` account to +/// the new reward vault account. +/// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. +/// 3. Packs all changes into account buffers. +pub(crate) fn process( + program_id: &Pubkey, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + let params = AddPoolRewardParams::new( + position_kind, + start_time_secs, + end_time_secs, + reward_token_amount, + )?; + + let accounts = + AddPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; + + // 1. + + spl_token_init_account(TokenInitializeAccountParams { + account: accounts.reward_token_vault_info.clone(), + mint: accounts.reward_mint_info.clone(), + owner: accounts.reward_authority_info.clone(), + rent: accounts.rent_info.clone(), + token_program: accounts.token_program_info.clone(), + })?; + let rent = &Rent::from_account_info(accounts.rent_info)?; + assert_rent_exempt(rent, accounts.reward_token_vault_info)?; + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_source_info.clone(), + destination: accounts.reward_token_vault_info.clone(), + amount: params.reward_token_amount, + authority: accounts.lending_market_owner_info.clone(), + authority_signer_seeds: &[], + token_program: accounts.token_program_info.clone(), + })?; + + // 2. + + // TODO: accounts.reserve.add_pool_reward(..) + + // 3. + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +impl AddPoolRewardParams { + fn new( + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + ) -> Result { + let clock = &Clock::get()?; + + let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); + + if start_time_secs <= end_time_secs { + msg!("Pool reward must end after it starts"); + return Err(LendingError::MathOverflow.into()); + } + + let duration_secs: u32 = { + // SAFETY: just checked that start time is strictly smaller + let d = end_time_secs - start_time_secs; + d.try_into().map_err(|_| { + msg!("Pool reward duration is too long"); + LendingError::MathOverflow + })? + }; + if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { + msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + if reward_token_amount == 0 { + msg!("Pool reward amount must be greater than zero"); + return Err(LendingError::InvalidAmount.into()); + } + + Ok(Self { + position_kind, + start_time_secs, + duration_secs, + reward_token_amount, + }) + } +} + +impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + params: &AddPoolRewardParams, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_source_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let rent_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + if reward_token_source_info.owner != token_program_info.key { + msg!("Reward token source provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_source = unpack_token_account(&reward_token_source_info.data.borrow())?; + if reward_token_source.owner != *lending_market_owner_info.key { + msg!("Reward token source owner does not match the lending market owner provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_source.amount >= params.reward_token_amount { + msg!("Reward token source is empty"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_source.mint != *reward_mint_info.key { + msg!("Reward token source mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reward_token_vault_info.owner != token_program_info.key { + msg!("Reward token vault provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + if !reward_token_vault_info.data.borrow().is_empty() { + msg!("Reward token vault provided must be empty"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_source_info.is_writable { + msg!("Reward token source provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + reserve_info, + reward_mint_info, + reward_token_source_info, + reward_authority_info, + reward_token_vault_info, + _lending_market_info: lending_market_info, + lending_market_owner_info, + rent_info, + token_program_info, + + reserve, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs new file mode 100644 index 00000000000..b1962fced57 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs @@ -0,0 +1,168 @@ +use crate::processor::liquidity_mining::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, +}; +use crate::processor::{spl_token_transfer, TokenTransferParams}; +use solana_program::program_pack::Pack; +use solana_program::sysvar::Sysvar; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use solend_sdk::{ + error::LendingError, + state::{PositionKind, Reserve}, +}; + +use super::reward_vault_authority_seeds; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct CancelPoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_destination_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ❓ we don't know whether it matches the reward vault pubkey stored in [Reserve] + /// ✅ is writable + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + _lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: Box, +} + +/// # Effects +/// +/// 1. Cancels any further reward emission, effectively setting end time to now. +/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. +/// 3. Packs all changes into account buffers. +pub(crate) fn process( + program_id: &Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + accounts: &[AccountInfo], +) -> ProgramResult { + let mut accounts = + CancelPoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // 1. + + let pool_reward_manager = match position_kind { + PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, + PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, + }; + let (expected_vault, unallocated_rewards) = + pool_reward_manager.cancel_pool_reward(pool_reward_index, &Clock::get()?)?; + + if expected_vault != *accounts.reward_token_vault_info.key { + msg!("Reward vault provided does not match the reward vault pubkey stored in [Reserve]"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // 2. + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.reward_token_destination_info.clone(), + amount: unallocated_rewards, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 3. + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_destination_info.is_writable { + msg!("Reward token destination provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + reserve_info, + reward_mint_info, + reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + _lending_market_owner_info: lending_market_owner_info, + token_program_info, + + reserve, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs new file mode 100644 index 00000000000..5737aba352d --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -0,0 +1,235 @@ +use crate::processor::{spl_token_transfer, TokenTransferParams}; +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, + sysvar::Sysvar, +}; +use solend_sdk::state::{CreatingNewUserRewardManager, Obligation}; +use solend_sdk::{ + error::LendingError, + state::{PositionKind, Reserve}, +}; + +use super::{ + check_and_unpack_pool_reward_accounts, reward_vault_authority_seeds, unpack_token_account, +}; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct ClaimUserReward<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ matches `lending_market_info` + /// ✅ is writable + obligation_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ is writable + /// ✅ matches `reward_mint_info` + /// ✅ owned by the obligation owner + obligation_owner_token_account_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + reward_mint_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ unpacks to a [TokenAccount] + /// ✅ owned by `reward_authority_info` + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + obligation: Box, + reserve: Box, +} + +/// # Effects +/// +/// 1. Updates the user reward manager with the pool reward manager and accrues rewards +/// 2. Withdraws all eligible rewards from [UserRewardManager]. +/// Eligible rewards are those that match the vault and user has earned any. +/// 3. Transfers the withdrawn rewards to the user's token account. +/// 4. Packs all changes into account buffers for [Obligation] and [Reserve]. +pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let mut accounts = ClaimUserReward::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // 1. + + let position_kind = accounts + .obligation + .find_position_kind(*accounts.reserve_info.key)?; + + let Some(user_reward_manager) = accounts + .obligation + .find_user_reward_manager_mut(*accounts.reserve_info.key) + else { + // Let's not error if a user has no rewards to claim for this reserve. + // Having this ix idempotent makes cranking easier. + return Ok(()); + }; + + let pool_reward_manager = match position_kind { + PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, + PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, + }; + + let clock = &Clock::get()?; + + // Syncs the pool reward manager with the user manager and accrues rewards. + // If we wanted to optimize CU usage then we could make a dedicated update + // function only for claiming rewards to avoid iterating twice over the rewards. + user_reward_manager.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; + + // 2. + + let total_reward_amount = user_reward_manager.claim_rewards( + pool_reward_manager, + *accounts.reward_token_vault_info.key, + clock, + )?; + + // 3. + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.obligation_owner_token_account_info.clone(), + amount: total_reward_amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 4. + + Obligation::pack( + *accounts.obligation, + &mut accounts.obligation_info.data.borrow_mut(), + )?; + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +impl<'a, 'info> ClaimUserReward<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let obligation_info = next_account_info(iter)?; + let obligation_owner_token_account_info = next_account_info(iter)?; + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + )?; + + if obligation_info.owner != program_id { + msg!("Obligation provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let obligation = Box::new(Obligation::unpack(&obligation_info.data.borrow())?); + + if obligation.lending_market != *lending_market_info.key { + msg!("Obligation lending market does not match the lending market provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if obligation_owner_token_account_info.owner != token_program_info.key { + msg!("Obligation owner token account provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let obligation_owner_token_account = + unpack_token_account(&obligation_owner_token_account_info.data.borrow())?; + + if obligation_owner_token_account.owner != obligation.owner { + msg!( + "Obligation owner token account owner does not match the obligation owner provided" + ); + return Err(LendingError::InvalidAccountInput.into()); + } + if obligation_owner_token_account.mint != *reward_mint_info.key { + msg!("Obligation owner token account mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if reward_token_vault_info.owner != token_program_info.key { + msg!("Reward token vault provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_vault = unpack_token_account(&reward_token_vault_info.data.borrow())?; + + if reward_token_vault.owner != *reward_authority_info.key { + msg!("Reward token vault owner does not match the reward authority provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + if reward_token_vault.mint != *reward_mint_info.key { + msg!("Reward token vault mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !obligation_info.is_writable { + msg!("Obligation provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !obligation_owner_token_account_info.is_writable { + msg!("Obligation owner token account provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + obligation_info, + obligation_owner_token_account_info, + reserve_info, + reward_mint_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + token_program_info, + + reserve, + obligation, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs new file mode 100644 index 00000000000..dae1990534f --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -0,0 +1,200 @@ +//! Closes a pool reward, making its slot vacant and ready for a new reward. +//! +//! Before closing a pool reward that pool reward must first be cancelled +//! and all rewards must be claimed by the users. +//! +//! The claim ix is permission-less and therefore it can be cranked. + +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; +use solend_sdk::{ + error::LendingError, + state::{PositionKind, Reserve}, +}; +use spl_token::state::Account as TokenAccount; + +use crate::processor::{ + spl_token_close_account, spl_token_transfer, TokenCloseAccountParams, TokenTransferParams, +}; + +use super::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, reward_vault_authority_seeds, + unpack_token_account, +}; + +/// Use [Self::from_unchecked_iter] to validate the accounts. +struct ClosePoolRewardAccounts<'a, 'info> { + /// ✅ belongs to this program + /// ✅ unpacks + /// ✅ belongs to `lending_market_info` + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + reward_mint_info: &'a AccountInfo<'info>, + /// ✅ belongs to the token program + /// ✅ owned by `lending_market_owner_info` + /// ✅ matches `reward_mint_info` + /// ✅ is writable + reward_token_destination_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + reward_authority_info: &'a AccountInfo<'info>, + /// ❓ we don't know whether it matches vault in the [Reserve] + /// ✅ is writable + /// ✅ unpacks + reward_token_vault_info: &'a AccountInfo<'info>, + /// ✅ belongs to this program + /// ✅ unpacks + lending_market_info: &'a AccountInfo<'info>, + /// ✅ is a signer + /// ✅ matches `lending_market_info` + lending_market_owner_info: &'a AccountInfo<'info>, + /// ✅ matches `lending_market_info` + token_program_info: &'a AccountInfo<'info>, + + reserve: Box, + reward_token_vault: TokenAccount, +} + +/// # Effects +/// +/// 1. Closes reward in the [Reserve] account if all users have claimed. +/// 2. Transfers dust to the `reward_token_destination` account. +/// 3. Closes reward vault token account. +/// 3. Packs all changes into account buffers. +pub(crate) fn process( + program_id: &Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + accounts: &[AccountInfo], +) -> ProgramResult { + let mut accounts = + ClosePoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // 1. + + let pool_reward_manager = match position_kind { + PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, + PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, + }; + let expected_vault = pool_reward_manager.close_pool_reward(pool_reward_index)?; + if expected_vault != *accounts.reward_token_vault_info.key { + msg!("Reward token vault provided does not match the expected vault"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // 2. + + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.reward_token_destination_info.clone(), + amount: accounts.reward_token_vault.amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 3. + + spl_token_close_account(TokenCloseAccountParams { + account: accounts.reward_token_vault_info.clone(), + destination: accounts.lending_market_owner_info.clone(), + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ), + token_program: accounts.token_program_info.clone(), + })?; + + // 4. + + Reserve::pack( + *accounts.reserve, + &mut accounts.reserve_info.data.borrow_mut(), + )?; + + Ok(()) +} + +impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let reward_mint_info = next_account_info(iter)?; + let reward_token_destination_info = next_account_info(iter)?; + let reward_authority_info = next_account_info(iter)?; + let reward_token_vault_info = next_account_info(iter)?; + let lending_market_info = next_account_info(iter)?; + let lending_market_owner_info = next_account_info(iter)?; + let token_program_info = next_account_info(iter)?; + + let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( + program_id, + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + )?; + + if reward_token_destination_info.owner != token_program_info.key { + msg!("Reward token destination provided must be owned by the token program"); + return Err(LendingError::InvalidTokenOwner.into()); + } + let reward_token_destination = + unpack_token_account(&reward_token_destination_info.data.borrow())?; + if reward_token_destination.mint != *reward_mint_info.key { + msg!("Reward token destination mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let reward_token_vault = unpack_token_account(&reward_token_vault_info.data.borrow())?; + if reward_token_vault.mint != *reward_mint_info.key { + msg!("Reward token vault mint does not match the reward mint provided"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_destination_info.is_writable { + msg!("Reward token destination provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reward_token_vault_info.is_writable { + msg!("Reward token vault provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + reserve_info, + reward_mint_info, + reward_token_destination_info, + reward_authority_info, + reward_token_vault_info, + lending_market_info, + lending_market_owner_info, + token_program_info, + + reserve, + reward_token_vault, + }) + } +} diff --git a/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs new file mode 100644 index 00000000000..35e97e5e773 --- /dev/null +++ b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs @@ -0,0 +1,146 @@ +use solana_program::program_pack::Pack; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program::invoke, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + system_instruction, + sysvar::Sysvar, +}; +use solend_sdk::state::discriminator::AccountDiscriminator; +use solend_sdk::state::RESERVE_LEN_V2_0_2; +use solend_sdk::{error::LendingError, state::Reserve}; + +struct UpgradeReserveAccounts<'a, 'info> { + /// Reserve sized as v2.0.2. + /// + /// ✅ belongs to this program + /// ✅ is sized [RESERVE_LEN_V2_0_2], ie. for sure [Reserve] account + /// ✅ is writable + reserve_info: &'a AccountInfo<'info>, + /// The pool fella who pays for this. + /// + /// ✅ is a signer + /// ✅ is writable + payer: &'a AccountInfo<'info>, + /// The system program. + /// + /// ✅ is the system program + system_program: &'a AccountInfo<'info>, +} + +/// Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. +/// Fails if reserve was not sized as @v2.0.2. +/// +/// Until this ix is called for a [Reserve] account, all other ixs that try to +/// unpack the [Reserve] will fail due to size mismatch. +/// +/// # Effects +/// +/// 1. Takes payer's lamports and pays for the rent increase. +/// 2. Reallocates the reserve account to the latest size. +/// 3. Repacks the reserve account. +pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts = UpgradeReserveAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + + // 1. + + let current_rent = accounts.reserve_info.lamports(); + let new_rent = Rent::get()?.minimum_balance(Reserve::LEN); + + if let Some(extra_rent) = new_rent.checked_sub(current_rent) { + // some reserves have more rent than necessary, let's not assume that + // the payer always needs to add more rent + + invoke( + &system_instruction::transfer( + accounts.payer.key, + accounts.reserve_info.key, + extra_rent, + ), + &[ + accounts.payer.clone(), + accounts.reserve_info.clone(), + accounts.system_program.clone(), + ], + )?; + } + + // 2. + + // From the [AccountInfo::realloc] docs: + // + // > Memory used to grow is already zero-initialized upon program entrypoint + // > and re-zeroing it wastes compute units. If within the same call a program + // > reallocs from larger to smaller and back to larger again the new space + // > could contain stale data. Pass true for zero_init in this case, + // > otherwise compute units will be wasted re-zero-initializing. + let zero_init = false; + accounts.reserve_info.realloc(Reserve::LEN, zero_init)?; + + // 3. + + // we upgrade discriminator as we've checked that the account is indeed + // a reserve account in [UpgradeReserveAccounts::from_unchecked_iter] + let mut data = accounts.reserve_info.data.borrow_mut(); + data[0] = AccountDiscriminator::Reserve as u8; + // Now the reserve can unpack fine and doesn't have to worry about + // migrations. + // Instead it returns an error on an invalid discriminator. + // This way a reserve cannot be mistaken for an obligation. + let reserve = Reserve::unpack(&data)?; + Reserve::pack(reserve, &mut data)?; + + Ok(()) +} + +impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> { + fn from_unchecked_iter( + program_id: &Pubkey, + iter: &mut impl Iterator>, + ) -> Result, ProgramError> { + let reserve_info = next_account_info(iter)?; + let payer = next_account_info(iter)?; + let system_program = next_account_info(iter)?; + + if !payer.is_signer { + msg!("Payer provided must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + + if reserve_info.owner != program_id { + msg!("Reserve provided must be owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + if reserve_info.data_len() != RESERVE_LEN_V2_0_2 { + msg!("Reserve provided must be sized as v2.0.2"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if system_program.key != &solana_program::system_program::id() { + msg!("System program provided must be the system program"); + return Err(LendingError::InvalidAccountInput.into()); + } + + // check that accounts that should be writable are writable + + if !payer.is_writable { + msg!("Payer provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + if !reserve_info.is_writable { + msg!("Reserve provided must be writable"); + return Err(ProgramError::InvalidAccountData); + } + + Ok(Self { + payer, + reserve_info, + system_program, + }) + } +} diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index f3b050d7687..a48d29270a1 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -223,6 +223,9 @@ pub enum LendingError { /// Trying to use an account that hasn't been migrated #[error("Trying to use an account that hasn't been migrated")] AccountNotMigrated, + /// There's no pool reward that matches the given parameters + #[error("There's no pool reward that matches the given parameters")] + NoPoolRewardMatches, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 5302817f46b..68d95c87468 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -578,13 +578,12 @@ pub enum LendingInstruction { /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[signer]` Lending market owner. - /// `[]` Rent sysvar. /// `[]` Token program. ClosePoolReward { /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// Identifies a reward within a reserve's deposits/borrows rewards. - pool_reward_index: u64, + pool_reward_index: usize, }, // 27 @@ -605,15 +604,33 @@ pub enum LendingInstruction { /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[signer]` Lending market owner. - /// `[]` Rent sysvar. /// `[]` Token program. CancelPoolReward { /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// Identifies a reward within a reserve's deposits/borrows rewards. - pool_reward_index: u64, + pool_reward_index: usize, }, + /// 28 + /// ClaimReward + /// + /// * User can claim rewards from their obligation. + /// + /// `[writable]` Obligation account. + /// `[writable]` Obligation owner reward receiving token account. + /// `[writable]` Reserve account. + /// `[]` Reward mint. + /// `[]` Derived reserve pool reward authority. Seed: + /// * b"RewardVaultAuthority" + /// * Lending market account pubkey + /// * Reserve account pubkey + /// * Reward mint pubkey + /// `[writable]` Reward vault token account. + /// `[]` Lending market account. + /// `[]` Token program. + ClaimReward, + // 255 /// UpgradeReserveToV2_1_0 /// @@ -900,7 +917,7 @@ impl LendingInstruction { let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; Self::ClosePoolReward { position_kind, - pool_reward_index, + pool_reward_index: pool_reward_index as _, } } 27 => { @@ -908,9 +925,10 @@ impl LendingInstruction { let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; Self::CancelPoolReward { position_kind, - pool_reward_index, + pool_reward_index: pool_reward_index as _, } } + 28 => Self::ClaimReward, 255 => Self::UpgradeReserveToV2_1_0, _ => { msg!("Instruction cannot be unpacked"); @@ -1248,6 +1266,9 @@ impl LendingInstruction { buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); } + Self::ClaimReward => { + buf.push(28); + } Self::UpgradeReserveToV2_1_0 => { buf.push(255); } diff --git a/token-lending/sdk/src/state/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index ada34c4d3ca..3185a69caa7 100644 --- a/token-lending/sdk/src/state/lending_market.rs +++ b/token-lending/sdk/src/state/lending_market.rs @@ -180,6 +180,7 @@ impl Pack for LendingMarket { msg!("Lending market discriminator does not match"); return Err(LendingError::InvalidAccountDiscriminator.into()); } + #[allow(clippy::assertions_on_constants)] Err(LendingError::AccountNotMigrated) => { // We're migrating the account from v2.0.2 to v2.1.0. // The reason this is safe to do is conveyed in these asserts: diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index fc59369ccb5..0c39cadc4b7 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -1,17 +1,18 @@ +use super::pack_decimal; use crate::{ error::LendingError, math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, state::unpack_decimal, }; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use solana_program::msg; use solana_program::program_pack::{Pack, Sealed}; use solana_program::{ clock::Clock, program_error::ProgramError, pubkey::{Pubkey, PUBKEY_BYTES}, }; - -use super::pack_decimal; +use std::convert::TryFrom; /// Determines the size of [PoolRewardManager] pub const MAX_REWARDS: usize = 50; @@ -74,6 +75,7 @@ pub enum PoolRewardSlot { /// Tracks rewards in a specific mint over some period of time. /// /// # Reward cancellation +/// /// In Suilend we also store the amount of rewards that have been made available /// to users already. /// We keep adding `(total_rewards * time_passed) / (total_time)` every @@ -92,6 +94,10 @@ pub struct PoolReward { /// Monotonically increasing time taken from clock sysvar. pub start_time_secs: u64, /// For how long (since start time) will this reward be releasing tokens. + /// + /// # Reward cancellation + /// + /// Is cut short if the reward is cancelled. pub duration_secs: u32, /// Total token amount to distribute. /// The token account that holds the rewards holds at least this much in @@ -169,6 +175,65 @@ pub struct UserReward { } impl PoolRewardManager { + /// Sets the duration of the pool reward to now. + /// Returns the amount of unallocated rewards and the vault they are in. + pub fn cancel_pool_reward( + &mut self, + pool_reward_index: usize, + clock: &Clock, + ) -> Result<(Pubkey, u64), ProgramError> { + self.update(clock)?; + + let Some(PoolRewardSlot::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot cancel a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.has_ended(clock) { + msg!("Cannot cancel a pool reward that has already ended"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; + let unlocked_rewards = Decimal::from(pool_reward.total_rewards) + .try_mul(Decimal::from(since_start_secs))? + .try_div(Decimal::from(pool_reward.duration_secs as u64))? + .try_floor_u64()?; + let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; + + pool_reward.duration_secs = + u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); + + Ok((pool_reward.vault, remaining_rewards)) + } + + /// Closes a pool reward if it has been cancelled before. + /// Returns the vault the rewards are in. + pub fn close_pool_reward(&mut self, pool_reward_index: usize) -> Result { + let Some(PoolRewardSlot::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot close a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.num_user_reward_managers > 0 { + msg!("Cannot close a pool reward with active user reward managers"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let vault = pool_reward.vault; + + self.pool_rewards[pool_reward_index] = PoolRewardSlot::Vacant { + last_pool_reward_id: pool_reward.id, + has_been_vacated_in_this_tx: true, + }; + + Ok(vault) + } + /// Should be updated before any interaction with rewards. fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> { let curr_unix_timestamp_secs = clock.unix_timestamp as u64; @@ -219,19 +284,70 @@ impl PoolRewardManager { } } -enum CreatingNewUserRewardManager { +/// When creating a new [UserRewardManager] we need to know whether we should +/// populate it with rewards or not. +pub enum CreatingNewUserRewardManager { /// If we are creating a [UserRewardManager] then we want to populate it. Yes, + /// If we are updating an existing [UserRewardManager] then we don't want + /// to populate it. No, } impl UserRewardManager { + /// Claims all rewards that the user has earned. + /// Returns how many tokens should be transferred to the user. + /// + /// # Note + /// Errors if there is no pool reward with this vault. + pub fn claim_rewards( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + vault: Pubkey, + clock: &Clock, + ) -> Result { + let (pool_reward_index, pool_reward) = pool_reward_manager + .pool_rewards + .iter() + .enumerate() + .find_map(move |(index, slot)| match slot { + PoolRewardSlot::Occupied(pool_reward) if pool_reward.vault == vault => { + Some((index, pool_reward)) + } + _ => None, + }) + .ok_or(LendingError::NoPoolRewardMatches)?; + + let Some(user_reward) = self.rewards.iter_mut().find(|user_reward| { + user_reward.pool_reward_index == pool_reward_index + && user_reward.pool_reward_id == pool_reward.id + }) else { + // User is not tracking this reward, nothing to claim. + // Let's be graceful and make this a no-op. + // Prevents failures when multiple parties crank rewards. + return Ok(0); + }; + + let to_claim = user_reward.withdraw_earned_rewards()?; + + if pool_reward.has_ended(clock) { + // If pool reward has ended then it will be removed from the user + // reward manager in the next update call. + // + // We could also complicate matters by doing updates in place when + // needed to save on CU if necessary. + self.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; + } + + Ok(to_claim) + } + /// Should be updated before any interaction with rewards. /// /// # Assumption /// Invoker has checked that this [PoolRewardManager] matches the /// [UserRewardManager]. - fn update( + pub fn update( &mut self, pool_reward_manager: &mut PoolRewardManager, clock: &Clock, @@ -257,23 +373,23 @@ impl UserRewardManager { continue; }; - let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; - let has_ended = self.last_update_time_secs > end_time_secs; - let maybe_user_reward = self .rewards .iter_mut() .enumerate() .find(|(_, r)| r.pool_reward_index == pool_reward_index); + let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; + let has_ended = self.last_update_time_secs > end_time_secs; + match maybe_user_reward { Some((user_reward_index, user_reward)) - if has_ended && user_reward.earned_rewards == Decimal::zero() => + if has_ended && user_reward.earned_rewards.try_floor_u64()? == 0 => { // Reward period ended and there's nothing to crank. // We can clean up this user reward. // We're fine with swap remove bcs `user_reward_index` is meaningless. - // SAFETY: We got the index from enumeration, so must exist/ + // SAFETY: We got the index from enumeration, so must exist. self.rewards.swap_remove(user_reward_index); pool_reward.num_user_reward_managers -= 1; } @@ -338,6 +454,12 @@ impl PoolReward { /// - `num_user_reward_managers`` /// - `cumulative_rewards_per_share`` const TAIL_LEN: usize = 8 + 4 + 8 + 8 + 16; + + /// Returns whether the reward has ended. + pub fn has_ended(&self, clock: &Clock) -> bool { + let end_time_secs = self.start_time_secs + self.duration_secs as u64; + clock.unix_timestamp as u64 > end_time_secs + } } impl PoolRewardId { @@ -452,6 +574,7 @@ impl Pack for PoolRewardManager { let offset = 8 + 8 + index * PoolReward::LEN; let raw_pool_reward_head = array_ref![input, offset, PoolReward::HEAD_LEN]; + #[allow(clippy::ptr_offset_with_cast)] let (src_id, src_vault) = array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; @@ -523,6 +646,20 @@ impl UserReward { /// - packed [Decimal] /// - packed [Decimal] pub const LEN: usize = 1 + PoolRewardId::LEN + 16 + 16; + + /// Removes all earned rewards from [Self] and returns them. + /// + /// # Note + /// Decimals are truncated to u64, dust is kept. + fn withdraw_earned_rewards(&mut self) -> Result { + let reward_amount = self.earned_rewards.try_floor_u64()?; + + if reward_amount > 0 { + self.earned_rewards = self.earned_rewards.try_sub(reward_amount.into())?; + } + + Ok(reward_amount) + } } impl UserRewardManager { @@ -600,8 +737,10 @@ impl UserRewardManager { } pub(crate) fn unpack_from_slice(input: &[u8]) -> Result { + #[allow(clippy::ptr_offset_with_cast)] let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; + #[allow(clippy::ptr_offset_with_cast)] let (src_reserve, src_share, src_last_update_time_secs, src_user_rewards_len) = array_refs![ raw_user_reward_manager_head, PUBKEY_BYTES, @@ -620,6 +759,7 @@ impl UserRewardManager { let offset = Self::HEAD_LEN + index * UserReward::LEN; let raw_user_reward = array_ref![input, offset, UserReward::LEN]; + #[allow(clippy::ptr_offset_with_cast)] let ( src_pool_reward_index, src_pool_reward_id, diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 8696853932b..94fcd8eccac 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -313,6 +313,38 @@ impl Obligation { .iter() .position(|liquidity| liquidity.borrow_reserve == borrow_reserve) } + + /// Find whether the reserve is a deposit or borrow + pub fn find_position_kind(&self, reserve: Pubkey) -> Result { + if self + .deposits + .iter() + .any(|collateral| collateral.deposit_reserve == reserve) + { + return Ok(PositionKind::Deposit); + } + + if self + .borrows + .iter() + .any(|liquidity| liquidity.borrow_reserve == reserve) + { + return Ok(PositionKind::Borrow); + } + + msg!("Reserve not found in obligation"); + Err(LendingError::InvalidAccountInput.into()) + } + + /// Returns [UserRewardManager] for the given reserve + pub fn find_user_reward_manager_mut( + &mut self, + reserve: Pubkey, + ) -> Option<&mut UserRewardManager> { + self.user_reward_managers + .iter_mut() + .find(|user_reward_manager| user_reward_manager.reserve == reserve) + } } /// Initialize an obligation @@ -469,7 +501,7 @@ impl Obligation { /// Unpacks from slice but returns an error if the account is already /// initialized. pub fn unpack_uninitialized(input: &[u8]) -> Result { - let account = Self::unpack_unchecked(&input)?; + let account = Self::unpack_unchecked(input)?; if account.is_initialized() { Err(LendingError::AlreadyInitialized.into()) } else { diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index c447e9b03a2..17d85438f23 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -1698,6 +1698,7 @@ impl Pack for Reserve { }; let input_v2_1_0 = array_ref![input, RESERVE_LEN_V2_0_2, PoolRewardManager::LEN * 2]; + #[allow(clippy::ptr_offset_with_cast)] let (input_for_borrows_pool_reward_manager, input_for_deposits_pool_reward_manager) = array_refs![input_v2_1_0, PoolRewardManager::LEN, PoolRewardManager::LEN]; From 590c58c95442f056457b3188568c4bb13c7c3d07 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 28 Mar 2025 20:55:35 +0100 Subject: [PATCH 08/19] Adding logic for adding pool reward ix (#206) --- .../liquidity_mining/add_pool_reward.rs | 89 ++++--------------- .../liquidity_mining/cancel_pool_reward.rs | 10 +-- .../liquidity_mining/claim_user_reward.rs | 14 +-- .../liquidity_mining/close_pool_reward.rs | 9 +- token-lending/sdk/src/error.rs | 3 + .../sdk/src/state/liquidity_mining.rs | 76 +++++++++++++++- token-lending/sdk/src/state/reserve.rs | 11 +++ 7 files changed, 117 insertions(+), 95 deletions(-) diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs index 540815a3b51..6f7fe6e5db9 100644 --- a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -17,27 +17,13 @@ use solana_program::{ rent::Rent, sysvar::Sysvar, }; -use solend_sdk::state::MIN_REWARD_PERIOD_SECS; use solend_sdk::{ error::LendingError, state::{PositionKind, Reserve}, }; -use std::convert::TryInto; use super::{check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account}; -/// Use [Self::new] to validate the parameters. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AddPoolRewardParams { - position_kind: PositionKind, - /// At least the current timestamp. - start_time_secs: u64, - /// Larger than [MIN_REWARD_PERIOD_SECS]. - duration_secs: u32, - /// Larger than zero. - reward_token_amount: u64, -} - /// Use [Self::from_unchecked_iter] to validate the accounts except for /// * `reward_token_vault_info` /// * `rent_info` @@ -51,7 +37,7 @@ struct AddPoolRewardAccounts<'a, 'info> { reward_mint_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program /// ✅ owned by `lending_market_owner_info` - /// ✅ has enough tokens + /// ❓ we don't know yet whether it has enough tokens /// ✅ matches `reward_mint_info` /// ✅ is writable reward_token_source_info: &'a AccountInfo<'info>, @@ -67,6 +53,7 @@ struct AddPoolRewardAccounts<'a, 'info> { _lending_market_info: &'a AccountInfo<'info>, /// ✅ is a signer /// ✅ matches `lending_market_info` + /// /// TBD: do we want to create another signer authority to be able to /// delegate reward management to a softer multisig? lending_market_owner_info: &'a AccountInfo<'info>, @@ -93,15 +80,10 @@ pub(crate) fn process( reward_token_amount: u64, accounts: &[AccountInfo], ) -> ProgramResult { - let params = AddPoolRewardParams::new( - position_kind, - start_time_secs, - end_time_secs, - reward_token_amount, - )?; + let clock = &Clock::get()?; - let accounts = - AddPoolRewardAccounts::from_unchecked_iter(program_id, ¶ms, &mut accounts.iter())?; + let mut accounts = + AddPoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; // 1. @@ -118,7 +100,7 @@ pub(crate) fn process( spl_token_transfer(TokenTransferParams { source: accounts.reward_token_source_info.clone(), destination: accounts.reward_token_vault_info.clone(), - amount: params.reward_token_amount, + amount: reward_token_amount, authority: accounts.lending_market_owner_info.clone(), authority_signer_seeds: &[], token_program: accounts.token_program_info.clone(), @@ -126,7 +108,16 @@ pub(crate) fn process( // 2. - // TODO: accounts.reserve.add_pool_reward(..) + accounts + .reserve + .pool_reward_manager_mut(position_kind) + .add_pool_reward( + *accounts.reward_token_vault_info.key, + start_time_secs, + end_time_secs, + reward_token_amount, + clock, + )?; // 3. @@ -138,53 +129,9 @@ pub(crate) fn process( Ok(()) } -impl AddPoolRewardParams { - fn new( - position_kind: PositionKind, - start_time_secs: u64, - end_time_secs: u64, - reward_token_amount: u64, - ) -> Result { - let clock = &Clock::get()?; - - let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); - - if start_time_secs <= end_time_secs { - msg!("Pool reward must end after it starts"); - return Err(LendingError::MathOverflow.into()); - } - - let duration_secs: u32 = { - // SAFETY: just checked that start time is strictly smaller - let d = end_time_secs - start_time_secs; - d.try_into().map_err(|_| { - msg!("Pool reward duration is too long"); - LendingError::MathOverflow - })? - }; - if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { - msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); - return Err(LendingError::PoolRewardPeriodTooShort.into()); - } - - if reward_token_amount == 0 { - msg!("Pool reward amount must be greater than zero"); - return Err(LendingError::InvalidAmount.into()); - } - - Ok(Self { - position_kind, - start_time_secs, - duration_secs, - reward_token_amount, - }) - } -} - impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, - params: &AddPoolRewardParams, iter: &mut impl Iterator>, ) -> Result, ProgramError> { let reserve_info = next_account_info(iter)?; @@ -216,10 +163,6 @@ impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { msg!("Reward token source owner does not match the lending market owner provided"); return Err(LendingError::InvalidAccountInput.into()); } - if reward_token_source.amount >= params.reward_token_amount { - msg!("Reward token source is empty"); - return Err(LendingError::InvalidAccountInput.into()); - } if reward_token_source.mint != *reward_mint_info.key { msg!("Reward token source mint does not match the reward mint provided"); return Err(LendingError::InvalidAccountInput.into()); diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs index b1962fced57..875947f1fb2 100644 --- a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs @@ -65,12 +65,10 @@ pub(crate) fn process( // 1. - let pool_reward_manager = match position_kind { - PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, - PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, - }; - let (expected_vault, unallocated_rewards) = - pool_reward_manager.cancel_pool_reward(pool_reward_index, &Clock::get()?)?; + let (expected_vault, unallocated_rewards) = accounts + .reserve + .pool_reward_manager_mut(position_kind) + .cancel_pool_reward(pool_reward_index, &Clock::get()?)?; if expected_vault != *accounts.reward_token_vault_info.key { msg!("Reward vault provided does not match the reward vault pubkey stored in [Reserve]"); diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index 5737aba352d..edd8f6653b1 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -10,10 +10,7 @@ use solana_program::{ sysvar::Sysvar, }; use solend_sdk::state::{CreatingNewUserRewardManager, Obligation}; -use solend_sdk::{ - error::LendingError, - state::{PositionKind, Reserve}, -}; +use solend_sdk::{error::LendingError, state::Reserve}; use super::{ check_and_unpack_pool_reward_accounts, reward_vault_authority_seeds, unpack_token_account, @@ -64,6 +61,8 @@ struct ClaimUserReward<'a, 'info> { /// 3. Transfers the withdrawn rewards to the user's token account. /// 4. Packs all changes into account buffers for [Obligation] and [Reserve]. pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let clock = &Clock::get()?; + let mut accounts = ClaimUserReward::from_unchecked_iter(program_id, &mut accounts.iter())?; // 1. @@ -81,12 +80,7 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR return Ok(()); }; - let pool_reward_manager = match position_kind { - PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, - PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, - }; - - let clock = &Clock::get()?; + let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); // Syncs the pool reward manager with the user manager and accrues rewards. // If we wanted to optimize CU usage then we could make a dedicated update diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs index dae1990534f..34d6bf4a769 100644 --- a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -78,11 +78,10 @@ pub(crate) fn process( // 1. - let pool_reward_manager = match position_kind { - PositionKind::Borrow => &mut accounts.reserve.borrows_pool_reward_manager, - PositionKind::Deposit => &mut accounts.reserve.deposits_pool_reward_manager, - }; - let expected_vault = pool_reward_manager.close_pool_reward(pool_reward_index)?; + let expected_vault = accounts + .reserve + .pool_reward_manager_mut(position_kind) + .close_pool_reward(pool_reward_index)?; if expected_vault != *accounts.reward_token_vault_info.key { msg!("Reward token vault provided does not match the expected vault"); return Err(LendingError::InvalidAccountInput.into()); diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index a48d29270a1..1bca3f2c3fb 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -226,6 +226,9 @@ pub enum LendingError { /// There's no pool reward that matches the given parameters #[error("There's no pool reward that matches the given parameters")] NoPoolRewardMatches, + /// There's no vacant slot for a pool reward + #[error("There's no vacant slot for a pool reward")] + NoVacantSlotForPoolReward, } impl From for ProgramError { diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 0c39cadc4b7..1afc68e4b6f 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -12,7 +12,7 @@ use solana_program::{ program_error::ProgramError, pubkey::{Pubkey, PUBKEY_BYTES}, }; -use std::convert::TryFrom; +use std::convert::{TryFrom, TryInto}; /// Determines the size of [PoolRewardManager] pub const MAX_REWARDS: usize = 50; @@ -175,6 +175,80 @@ pub struct UserReward { } impl PoolRewardManager { + /// Adds a new pool reward. + /// + /// Will first update itself. + /// + /// Start time will be set to now if it's in the past. + /// Must last at least [MIN_REWARD_PERIOD_SECS]. + /// The amount of tokens to distribute must be greater than zero. + /// + /// Will return an error if no slot can be found for the new reward. + pub fn add_pool_reward( + &mut self, + vault: Pubkey, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update(clock)?; + + let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); + + if start_time_secs <= end_time_secs { + msg!("Pool reward must end after it starts"); + return Err(LendingError::MathOverflow.into()); + } + + let duration_secs: u32 = { + // SAFETY: just checked that start time is strictly smaller + let d = end_time_secs - start_time_secs; + d.try_into().map_err(|_| { + msg!("Pool reward duration is too long"); + LendingError::MathOverflow + })? + }; + if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { + msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + if reward_token_amount == 0 { + msg!("Pool reward amount must be greater than zero"); + return Err(LendingError::InvalidAmount.into()); + } + + let eligible_slot = + self.pool_rewards + .iter_mut() + .enumerate() + .find_map(|(slot_index, slot)| match slot { + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(id), + .. + } if *id < u32::MAX => Some((slot_index, PoolRewardId(*id + 1))), + _ => None, + }); + + let Some((slot_index, next_id)) = eligible_slot else { + msg!("No vacant slot found for the new pool reward"); + return Err(LendingError::NoVacantSlotForPoolReward.into()); + }; + + self.pool_rewards[slot_index] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: next_id, + vault, + start_time_secs, + duration_secs, + total_rewards: reward_token_amount, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + Ok(()) + } + /// Sets the duration of the pool reward to now. /// Returns the amount of unallocated rewards and the vault they are in. pub fn cancel_pool_reward( diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index 17d85438f23..d2f4def8193 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -593,6 +593,17 @@ impl Reserve { .try_floor_u64()?, )) } + + /// Returns the pool reward manager for the given position kind + pub fn pool_reward_manager_mut( + &mut self, + position_kind: PositionKind, + ) -> &mut PoolRewardManager { + match position_kind { + PositionKind::Borrow => &mut self.borrows_pool_reward_manager, + PositionKind::Deposit => &mut self.deposits_pool_reward_manager, + } + } } /// Initialize a reserve From 4e58ca78256da6aed7e35c5f6ba2727a6f5a4780 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Sun, 6 Apr 2025 16:53:34 +0200 Subject: [PATCH 09/19] [Liquidity Mining] Obligation realloc (8) (#207) * Improving Reserve pack, unpack in ixs * Progress with realloc tests * Adding tests for shares tracking * Removing unused comment * More tests * Addressing clippy review * Downgrading package lock * All BPF tests pass * Fixing repay tests in obligation * CU units are reverted back to their original values * Emit program logs on CI * Adding Suilend test 'test_pool_reward_manager_basic' * Adding Solend test 'test_pool_reward_manager_multiple_rewards' * Adding more Suilend tests * Copying remaining liq. mining tests from Suilend * Increasing CU budget allowances * Fixing ix mutable borrows --- Cargo.lock | 26 +- Cargo.toml | 1 + ci/cargo-test-bpf.sh | 6 +- coverage.sh | 5 +- token-lending/program/Cargo.toml | 19 +- token-lending/program/src/processor.rs | 506 +++++++------ .../program/src/processor/account_borrow.rs | 216 ++++++ .../program/src/processor/liquidity_mining.rs | 25 +- .../liquidity_mining/add_pool_reward.rs | 24 +- .../liquidity_mining/cancel_pool_reward.rs | 24 +- .../liquidity_mining/claim_user_reward.rs | 93 ++- .../liquidity_mining/close_pool_reward.rs | 18 +- .../program/tests/attributed_borrows.rs | 19 +- .../tests/borrow_obligation_liquidity.rs | 52 +- token-lending/program/tests/borrow_weight.rs | 2 +- .../tests/deposit_obligation_collateral.rs | 49 +- ...rve_liquidity_and_obligation_collateral.rs | 46 +- token-lending/program/tests/forgive_debt.rs | 9 +- .../tests/helpers/solend_program_test.rs | 58 +- .../program/tests/init_obligation.rs | 2 +- .../program/tests/isolated_tier_assets.rs | 37 +- .../program/tests/liquidate_obligation.rs | 2 +- ...uidate_obligation_and_redeem_collateral.rs | 59 +- .../program/tests/outflow_rate_limits.rs | 2 +- .../tests/repay_obligation_liquidity.rs | 31 +- token-lending/program/tests/two_prices.rs | 6 +- .../tests/withdraw_obligation_collateral.rs | 74 +- ...ollateral_and_redeem_reserve_collateral.rs | 22 +- token-lending/sdk/Cargo.toml | 1 + token-lending/sdk/src/instruction.rs | 6 +- .../sdk/src/state/liquidity_mining.rs | 710 ++++++++++++++++-- token-lending/sdk/src/state/obligation.rs | 81 +- token-lending/sdk/src/state/rate_limiter.rs | 2 +- 33 files changed, 1757 insertions(+), 476 deletions(-) create mode 100644 token-lending/program/src/processor/account_borrow.rs diff --git a/Cargo.lock b/Cargo.lock index 37fe074561d..75fb11b8532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1469,6 +1469,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -3276,6 +3282,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi 1.0.1", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3356,7 +3372,7 @@ dependencies = [ "quote 1.0.36", "syn 1.0.109", "version_check", - "yansi", + "yansi 0.5.1", ] [[package]] @@ -5422,6 +5438,7 @@ dependencies = [ "bytemuck", "log", "oracles", + "pretty_assertions", "proptest", "pyth-sdk-solana", "pyth-solana-receiver-sdk", @@ -5472,6 +5489,7 @@ dependencies = [ "log", "num-derive 0.3.3", "num-traits", + "pretty_assertions", "proptest", "rand 0.8.5", "serde", @@ -7186,6 +7204,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 829068fcb4e..40d3135d61f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "token-lending/cli", "token-lending/program", diff --git a/ci/cargo-test-bpf.sh b/ci/cargo-test-bpf.sh index ea30a02f18a..b40acbeca45 100755 --- a/ci/cargo-test-bpf.sh +++ b/ci/cargo-test-bpf.sh @@ -8,7 +8,7 @@ source ./ci/solana-version.sh export RUSTFLAGS="-D warnings" export RUSTBACKTRACE=1 - +export RUST_LOG="warn,tarpc=error,solana_runtime::message_processor=debug" usage() { exitcode=0 @@ -33,11 +33,11 @@ run_dir=$(pwd) if [[ -d $run_dir/program ]]; then # Build/test just one BPF program cd $run_dir/program - RUST_LOG="error" cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture + cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture else # Build/test all BPF programs for directory in $(ls -d $run_dir/*/); do cd $directory - RUST_LOG="error" cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture + cargo +"$rust_stable" test-bpf --features test-bpf -j 1 -- --nocapture done fi diff --git a/coverage.sh b/coverage.sh index 3bef941b59a..30e23283fce 100755 --- a/coverage.sh +++ b/coverage.sh @@ -21,10 +21,11 @@ RUST_LOG="error" CARGO_INCREMENTAL=0 RUSTFLAGS='-Cinstrument-coverage' LLVM_PROF # generate report mkdir -p target/coverage/html -grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/html +grcov . --branch --binary-path ./target/debug/deps/ -s . -t html --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/html -grcov . --binary-path ./target/debug/deps/ -s . -t lcov --branch --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov +grcov . --branch --binary-path ./target/debug/deps/ -s . -t lcov --ignore-not-existing --ignore '../*' --ignore "/*" -o target/coverage/tests.lcov # cleanup rm *.profraw || true rm **/**/*.profraw || true + diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index 44661e3a6ac..ae8a379dd44 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -8,6 +8,8 @@ license = "Apache-2.0" edition = "2018" [features] +custom-heap = [] +custom-panic = [] no-entrypoint = [] test-bpf = [] @@ -24,22 +26,23 @@ static_assertions = "1.1.0" [dev-dependencies] anchor-lang = "0.28.0" assert_matches = "1.5.0" -bytemuck = "1.5.1" base64 = "0.13" -log = "0.4.14" -proptest = "1.0" -solana-program-test = "=1.16.20" -solana-sdk = "=1.16.20" -serde = ">=1.0.140" -serde_yaml = "0.8" -thiserror = "1.0" bincode = "1.3.3" borsh = "0.10.3" +bytemuck = "1.5.1" +log = "0.4.14" +pretty_assertions = "1.4.1" +proptest = "1.0" pyth-sdk-solana = "0.8.0" pyth-solana-receiver-sdk = "0.3.0" +serde = ">=1.0.140" +serde_yaml = "0.8" +solana-program-test = "=1.16.20" +solana-sdk = "=1.16.20" switchboard-on-demand = "0.1.12" switchboard-program = "0.2.0" switchboard-v2 = "0.1.3" +thiserror = "1.0" [lib] crate-type = ["cdylib", "lib"] diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index ac16c902381..55e58f04587 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -1,5 +1,6 @@ //! Program state processor +mod account_borrow; mod liquidity_mining; use crate::state::Bonus; @@ -15,6 +16,7 @@ use crate::{ ReserveCollateral, ReserveConfig, ReserveLiquidity, }, }; +use account_borrow::ReserveBorrow; use bytemuck::bytes_of; use oracles::get_single_price; use oracles::get_single_price_unchecked; @@ -37,6 +39,7 @@ use solana_program::{ sysvar::instructions::{load_current_index_checked, load_instruction_at_checked}, sysvar::{clock::Clock, rent::Rent, Sysvar}, }; +use solend_sdk::state::PositionKind; use solend_sdk::{ math::SaturatingSub, state::{LendingMarketMetadata, RateLimiter, RateLimiterConfig, ReserveType}, @@ -470,7 +473,6 @@ fn process_init_reserve( }); let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; - Reserve::pack(reserve, &mut reserve_info.data.borrow_mut())?; spl_token_init_account(TokenInitializeAccountParams { account: reserve_liquidity_supply_info.clone(), @@ -530,7 +532,7 @@ fn process_init_reserve( token_program: token_program_id.clone(), })?; - Ok(()) + Reserve::pack(reserve, &mut reserve_info.data.borrow_mut()) } fn validate_extra_oracle( @@ -573,29 +575,27 @@ fn process_refresh_reserve(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro let clock = &Clock::get()?; let extra_oracle_account_info = next_account_info(account_info_iter).ok(); + + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + _refresh_reserve( - program_id, - reserve_info, + &mut reserve, pyth_price_info, Some(switchboard_feed_info), clock, extra_oracle_account_info, - ) + )?; + + Ok(()) } fn _refresh_reserve<'a>( - program_id: &Pubkey, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, pyth_price_info: &AccountInfo<'a>, switchboard_feed_info: Option<&AccountInfo<'a>>, clock: &Clock, extra_oracle_account_info: Option<&AccountInfo<'a>>, ) -> ProgramResult { - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.liquidity.pyth_oracle_pubkey != pyth_price_info.key { msg!("Reserve liquidity pyth oracle does not match the reserve liquidity pyth oracle provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -647,27 +647,16 @@ fn _refresh_reserve<'a>( reserve.liquidity.smoothed_market_price = reserve.liquidity.market_price; } - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; + _refresh_reserve_interest(reserve, clock)?; - _refresh_reserve_interest(program_id, reserve_info, clock) + Ok(()) } /// Lite version of refresh_reserve that should be used when the oracle price doesn't need to be updated /// BE CAREFUL WHEN USING THIS -fn _refresh_reserve_interest( - program_id: &Pubkey, - reserve_info: &AccountInfo<'_>, - clock: &Clock, -) -> ProgramResult { - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } - +fn _refresh_reserve_interest(reserve: &mut ReserveBorrow, clock: &Clock) -> ProgramResult { reserve.accrue_interest(clock.slot)?; reserve.last_update.update_slot(clock.slot); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; Ok(()) } @@ -694,13 +683,15 @@ fn process_deposit_reserve_liquidity( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, clock)?; _deposit_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, destination_collateral_info, - reserve_info, + &mut reserve, reserve_liquidity_supply_info, reserve_collateral_mint_info, lending_market_info, @@ -719,7 +710,7 @@ fn _deposit_reserve_liquidity<'a>( liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, reserve_liquidity_supply_info: &AccountInfo<'a>, reserve_collateral_mint_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -737,11 +728,6 @@ fn _deposit_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -792,7 +778,6 @@ fn _deposit_reserve_liquidity<'a>( let collateral_amount = reserve.deposit_liquidity(liquidity_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -837,12 +822,14 @@ fn process_redeem_reserve_collateral( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + _redeem_reserve_collateral( program_id, collateral_amount, source_collateral_info, destination_liquidity_info, - reserve_info, + &mut reserve, reserve_collateral_mint_info, reserve_liquidity_supply_info, lending_market_info, @@ -852,10 +839,8 @@ fn process_redeem_reserve_collateral( token_program_id, true, )?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; + reserve.last_update.mark_stale(); Ok(()) } @@ -865,7 +850,7 @@ fn _redeem_reserve_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_liquidity_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, reserve_collateral_mint_info: &AccountInfo<'a>, reserve_liquidity_supply_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -885,11 +870,6 @@ fn _redeem_reserve_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -952,7 +932,6 @@ fn _redeem_reserve_collateral<'a>( } reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; LendingMarket::pack(lending_market, &mut lending_market_info.data.borrow_mut())?; spl_token_burn(TokenBurnParams { @@ -1042,14 +1021,9 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> for (index, collateral) in obligation.deposits.iter_mut().enumerate() { let deposit_reserve_info = next_account_info(account_info_iter)?; - if deposit_reserve_info.owner != program_id { - msg!( - "Deposit reserve provided for collateral {} is not owned by the lending program", - index - ); - return Err(LendingError::InvalidAccountOwner.into()); - } - if collateral.deposit_reserve != *deposit_reserve_info.key { + let deposit_reserve = ReserveBorrow::new(program_id, deposit_reserve_info)?; + + if collateral.deposit_reserve != deposit_reserve.key() { msg!( "Deposit reserve of collateral {} does not match the deposit reserve provided", index @@ -1057,7 +1031,6 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> return Err(LendingError::InvalidAccountInput.into()); } - let deposit_reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); if deposit_reserve.last_update.is_stale(clock.slot)? { msg!( "Deposit reserve provided for collateral {} is stale and must be refreshed in the current slot", @@ -1094,14 +1067,9 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> let mut max_borrow_weight = None; for (index, liquidity) in obligation.borrows.iter_mut().enumerate() { let borrow_reserve_info = next_account_info(account_info_iter)?; - if borrow_reserve_info.owner != program_id { - msg!( - "Borrow reserve provided for liquidity {} is not owned by the lending program", - index - ); - return Err(LendingError::InvalidAccountOwner.into()); - } - if liquidity.borrow_reserve != *borrow_reserve_info.key { + let borrow_reserve = ReserveBorrow::new(program_id, borrow_reserve_info)?; + + if liquidity.borrow_reserve != borrow_reserve.key() { msg!( "Borrow reserve of liquidity {} does not match the borrow reserve provided", index @@ -1109,7 +1077,6 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> return Err(LendingError::InvalidAccountInput.into()); } - let borrow_reserve = Box::new(Reserve::unpack(&borrow_reserve_info.data.borrow())?); if borrow_reserve.last_update.is_stale(clock.slot)? { msg!( "Borrow reserve provided for liquidity {} is stale and must be refreshed in the current slot", @@ -1180,7 +1147,8 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> obligation.last_update.update_slot(clock.slot); - let (_, close_exceeded) = update_borrow_attribution_values(&mut obligation, &accounts[1..])?; + let (_, close_exceeded) = + update_borrow_attribution_values(program_id, &mut obligation, &accounts[1..])?; if close_exceeded.is_none() { obligation.closeable = false; } @@ -1198,6 +1166,7 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> .borrows .retain(|liquidity| liquidity.borrowed_amount_wads > Decimal::zero()); + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -1211,8 +1180,13 @@ fn process_refresh_obligation(program_id: &Pubkey, accounts: &[AccountInfo]) -> /// - the obligation's deposited_value must be refreshed /// - the obligation's true_borrowed_value must be refreshed /// -/// Note that this function packs and unpacks deposit reserves. +/// # Important +/// +/// This function packs and unpacks deposit reserves. +/// This means that any [ReserveBorrow] whose data might be processed in this +/// function needs to be released. fn update_borrow_attribution_values( + program_id: &Pubkey, obligation: &mut Obligation, deposit_reserve_infos: &[AccountInfo], ) -> Result<(Option, Option), ProgramError> { @@ -1223,7 +1197,7 @@ fn update_borrow_attribution_values( for collateral in obligation.deposits.iter_mut() { let deposit_reserve_info = next_account_info(deposit_infos)?; - let mut deposit_reserve = Reserve::unpack(&deposit_reserve_info.data.borrow())?; + let mut deposit_reserve = ReserveBorrow::new_mut(program_id, deposit_reserve_info)?; // sanity check if collateral.deposit_reserve != *deposit_reserve_info.key { @@ -1258,8 +1232,6 @@ fn update_borrow_attribution_values( { close_exceeded = Some(*deposit_reserve_info.key); } - - Reserve::pack(deposit_reserve, &mut deposit_reserve_info.data.borrow_mut())?; } Ok((open_exceeded, close_exceeded)) @@ -1286,13 +1258,16 @@ fn process_deposit_obligation_collateral( let user_transfer_authority_info = next_account_info(account_info_iter)?; let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, deposit_reserve_info, clock)?; + + let mut deposit_reserve = ReserveBorrow::new_mut(program_id, deposit_reserve_info)?; + + _refresh_reserve_interest(&mut deposit_reserve, clock)?; _deposit_obligation_collateral( program_id, collateral_amount, source_collateral_info, destination_collateral_info, - deposit_reserve_info, + &mut deposit_reserve, obligation_info, lending_market_info, obligation_owner_info, @@ -1300,9 +1275,8 @@ fn process_deposit_obligation_collateral( clock, token_program_id, )?; - let mut reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); - reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut deposit_reserve_info.data.borrow_mut())?; + + deposit_reserve.last_update.mark_stale(); Ok(()) } @@ -1312,7 +1286,7 @@ fn _deposit_obligation_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - deposit_reserve_info: &AccountInfo<'a>, + deposit_reserve: &mut ReserveBorrow, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, obligation_owner_info: &AccountInfo<'a>, @@ -1330,11 +1304,6 @@ fn _deposit_obligation_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let deposit_reserve = Box::new(Reserve::unpack(&deposit_reserve_info.data.borrow())?); - if deposit_reserve_info.owner != program_id { - msg!("Deposit reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &deposit_reserve.lending_market != lending_market_info.key { msg!("Deposit reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1372,11 +1341,24 @@ fn _deposit_obligation_collateral<'a>( return Err(LendingError::InvalidSigner.into()); } - obligation - .find_or_add_collateral_to_deposits(*deposit_reserve_info.key)? - .deposit(collateral_amount)?; + let collateral = obligation.find_or_add_collateral_to_deposits(deposit_reserve.key())?; + collateral.deposit(collateral_amount)?; + + // liq. mining + let new_share = collateral.deposited_amount; + obligation.user_reward_managers.set_share( + deposit_reserve.key(), + PositionKind::Deposit, + &mut deposit_reserve.deposits_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; + spl_token_transfer(TokenTransferParams { source: source_collateral_info.clone(), destination: destination_collateral_info.clone(), @@ -1416,13 +1398,15 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, clock)?; let collateral_amount = _deposit_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, user_collateral_info, - reserve_info, + &mut reserve, reserve_liquidity_supply_info, reserve_collateral_mint_info, lending_market_info, @@ -1431,13 +1415,13 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( clock, token_program_id, )?; - _refresh_reserve_interest(program_id, reserve_info, clock)?; + _refresh_reserve_interest(&mut reserve, clock)?; _deposit_obligation_collateral( program_id, collateral_amount, user_collateral_info, destination_collateral_info, - reserve_info, + &mut reserve, obligation_info, lending_market_info, obligation_owner_info, @@ -1445,11 +1429,9 @@ fn process_deposit_reserve_liquidity_and_obligation_collateral( clock, token_program_id, )?; + // mark the reserve as stale to make sure no weird bugs happen - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - Ok(()) } @@ -1474,12 +1456,15 @@ fn process_withdraw_obligation_collateral( let obligation_owner_info = next_account_info(account_info_iter)?; let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; + + let mut withdraw_reserve = ReserveBorrow::new_mut(program_id, withdraw_reserve_info)?; + _withdraw_obligation_collateral( program_id, collateral_amount, source_collateral_info, destination_collateral_info, - withdraw_reserve_info, + &mut withdraw_reserve, obligation_info, lending_market_info, lending_market_authority_info, @@ -1489,6 +1474,7 @@ fn process_withdraw_obligation_collateral( false, &accounts[8..], )?; + Ok(()) } @@ -1498,7 +1484,7 @@ fn _withdraw_obligation_collateral<'a>( collateral_amount: u64, source_collateral_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - withdraw_reserve_info: &AccountInfo<'a>, + withdraw_reserve: &mut ReserveBorrow, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, lending_market_authority_info: &AccountInfo<'a>, @@ -1518,11 +1504,6 @@ fn _withdraw_obligation_collateral<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); - if withdraw_reserve_info.owner != program_id { - msg!("Withdraw reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &withdraw_reserve.lending_market != lending_market_info.key { msg!("Withdraw reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1563,7 +1544,7 @@ fn _withdraw_obligation_collateral<'a>( } let (collateral, collateral_index) = - obligation.find_collateral_in_deposits(*withdraw_reserve_info.key)?; + obligation.find_collateral_in_deposits(withdraw_reserve.key())?; if collateral.deposited_amount == 0 { msg!("Collateral deposited amount is zero"); return Err(LendingError::ObligationCollateralEmpty.into()); @@ -1616,7 +1597,7 @@ fn _withdraw_obligation_collateral<'a>( u64::MAX }; - let max_withdraw_amount = obligation.max_withdraw_amount(collateral, &withdraw_reserve)?; + let max_withdraw_amount = obligation.max_withdraw_amount(collateral, withdraw_reserve)?; let withdraw_amount = min( collateral_amount, min(max_withdraw_amount, max_outflow_collateral_amount), @@ -1640,8 +1621,10 @@ fn _withdraw_obligation_collateral<'a>( .market_value .saturating_sub(withdraw_value); - let (open_exceeded, _) = - update_borrow_attribution_values(&mut obligation, deposit_reserve_infos)?; + let (open_exceeded, _) = withdraw_reserve.while_released(|| { + update_borrow_attribution_values(program_id, &mut obligation, deposit_reserve_infos) + })?; + if let Some(reserve_pubkey) = open_exceeded { msg!( "Open borrow attribution limit exceeded for reserve {:?}", @@ -1652,9 +1635,20 @@ fn _withdraw_obligation_collateral<'a>( // obligation.withdraw must be called after updating borrow attribution values, since we can // lose information if an entire deposit is removed, making the former calculation incorrect - obligation.withdraw(withdraw_amount, collateral_index)?; + let new_share = obligation.withdraw(withdraw_amount, collateral_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + withdraw_reserve.key(), + PositionKind::Deposit, + &mut withdraw_reserve.deposits_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -1702,11 +1696,8 @@ fn process_borrow_obligation_liquidity( return Err(LendingError::InvalidTokenProgram.into()); } - let mut borrow_reserve = Box::new(Reserve::unpack(&borrow_reserve_info.data.borrow())?); - if borrow_reserve_info.owner != program_id { - msg!("Borrow reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut borrow_reserve = ReserveBorrow::new_mut(program_id, borrow_reserve_info)?; + if &borrow_reserve.lending_market != lending_market_info.key { msg!("Borrow reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -1892,15 +1883,29 @@ fn process_borrow_obligation_liquidity( .unweighted_borrowed_value .try_add(borrow_reserve.market_value(borrow_amount)?)?; - Reserve::pack(*borrow_reserve, &mut borrow_reserve_info.data.borrow_mut())?; - let obligation_liquidity = obligation .find_or_add_liquidity_to_borrows(*borrow_reserve_info.key, cumulative_borrow_rate_wads)?; obligation_liquidity.borrow(borrow_amount)?; + + // liq. mining + let new_share = obligation_liquidity.liability_shares()?; + obligation.user_reward_managers.set_share( + borrow_reserve.key(), + PositionKind::Borrow, + &mut borrow_reserve.borrows_pool_reward_manager, + new_share, + clock, + )?; + obligation.last_update.mark_stale(); - let (open_exceeded, _) = update_borrow_attribution_values(&mut obligation, &accounts[9..])?; + // because [update_borrow_attribution_values] takes reference to the data + // we need to drop our borrow + borrow_reserve.commit(); + + let (open_exceeded, _) = + update_borrow_attribution_values(program_id, &mut obligation, &accounts[9..])?; if let Some(reserve_pubkey) = open_exceeded { msg!( "Open borrow attribution limit exceeded for reserve {:?}", @@ -1914,6 +1919,7 @@ fn process_borrow_obligation_liquidity( next_account_info(account_info_iter)?; } + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; let mut owner_fee = borrow_fee; @@ -1986,12 +1992,10 @@ fn process_repay_obligation_liquidity( return Err(LendingError::InvalidTokenProgram.into()); } - _refresh_reserve_interest(program_id, repay_reserve_info, clock)?; - let mut repay_reserve = Box::new(Reserve::unpack(&repay_reserve_info.data.borrow())?); - if repay_reserve_info.owner != program_id { - msg!("Repay reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut repay_reserve = ReserveBorrow::new_mut(program_id, repay_reserve_info)?; + + _refresh_reserve_interest(&mut repay_reserve, clock)?; + if &repay_reserve.lending_market != lending_market_info.key { msg!("Repay reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2041,10 +2045,20 @@ fn process_repay_obligation_liquidity( repay_reserve.liquidity.repay(repay_amount, settle_amount)?; repay_reserve.last_update.mark_stale(); - Reserve::pack(*repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - obligation.repay(settle_amount, liquidity_index)?; + let new_share = obligation.repay(settle_amount, liquidity_index)?; obligation.last_update.mark_stale(); + + // liq. mining + obligation.user_reward_managers.set_share( + repay_reserve.key(), + PositionKind::Borrow, + &mut repay_reserve.borrows_pool_reward_manager, + new_share, + clock, + )?; + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -2059,15 +2073,22 @@ fn process_repay_obligation_liquidity( Ok(()) } +/// Because repay and withdraw reserve can match we cannot have both of them +/// mutably borrowed at the same time. +/// +/// This function assumes that both reserves are given in read only state and +/// will mutably borrow them inside the function in a safe fashion. +/// +/// When the function returns the reserves are in a read only state again. #[allow(clippy::too_many_arguments)] fn _liquidate_obligation<'a>( program_id: &Pubkey, liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_collateral_info: &AccountInfo<'a>, - repay_reserve_info: &AccountInfo<'a>, + repay_reserve: &mut ReserveBorrow, repay_reserve_liquidity_supply_info: &AccountInfo<'a>, - withdraw_reserve_info: &AccountInfo<'a>, + withdraw_reserve: &mut ReserveBorrow, withdraw_reserve_collateral_supply_info: &AccountInfo<'a>, obligation_info: &AccountInfo<'a>, lending_market_info: &AccountInfo<'a>, @@ -2086,11 +2107,6 @@ fn _liquidate_obligation<'a>( return Err(LendingError::InvalidTokenProgram.into()); } - let mut repay_reserve = Box::new(Reserve::unpack(&repay_reserve_info.data.borrow())?); - if repay_reserve_info.owner != program_id { - msg!("Repay reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &repay_reserve.lending_market != lending_market_info.key { msg!("Repay reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2114,11 +2130,6 @@ fn _liquidate_obligation<'a>( return Err(LendingError::ReserveStale.into()); } - let mut withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); - if withdraw_reserve_info.owner != program_id { - msg!("Withdraw reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } if &withdraw_reserve.lending_market != lending_market_info.key { msg!("Withdraw reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2174,8 +2185,7 @@ fn _liquidate_obligation<'a>( } } - let (liquidity, liquidity_index) = - obligation.find_liquidity_in_borrows(*repay_reserve_info.key)?; + let (liquidity, liquidity_index) = obligation.find_liquidity_in_borrows(repay_reserve.key())?; if liquidity.market_value == Decimal::zero() { msg!("Obligation borrow value is zero"); return Err(LendingError::ObligationLiquidityEmpty.into()); @@ -2186,7 +2196,7 @@ fn _liquidate_obligation<'a>( } let (collateral, collateral_index) = - obligation.find_collateral_in_deposits(*withdraw_reserve_info.key)?; + obligation.find_collateral_in_deposits(withdraw_reserve.key())?; if collateral.market_value == Decimal::zero() { msg!("Obligation deposit value is zero"); return Err(LendingError::ObligationCollateralEmpty.into()); @@ -2227,28 +2237,69 @@ fn _liquidate_obligation<'a>( return Err(LendingError::LiquidationTooSmall.into()); } - repay_reserve.liquidity.repay(repay_amount, settle_amount)?; - repay_reserve.last_update.mark_stale(); - Reserve::pack(*repay_reserve, &mut repay_reserve_info.data.borrow_mut())?; - - // if there is a full withdraw here (which can happen on a full liquidation), then the borrow - // attribution value needs to be updated on the reserve. note that we can't depend on - // refresh_obligation to update this correctly because the ObligationCollateral object will be - // deleted after this call. - if withdraw_amount == collateral.deposited_amount { - withdraw_reserve.attributed_borrow_value = withdraw_reserve - .attributed_borrow_value - .saturating_sub(collateral.market_value); + let collateral_deposited_amount = collateral.deposited_amount; + let collateral_market_value = collateral.market_value; - Reserve::pack( - *withdraw_reserve, - &mut withdraw_reserve_info.data.borrow_mut(), + { + // we need to update the repay reserve but in order to do that we need to + // release the withdraw reserve first + withdraw_reserve.release()?; + repay_reserve.acquire_reload_mut()?; + + repay_reserve.liquidity.repay(repay_amount, settle_amount)?; + repay_reserve.last_update.mark_stale(); + let new_share = obligation.repay(settle_amount, liquidity_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + repay_reserve.key(), + PositionKind::Borrow, + &mut repay_reserve.borrows_pool_reward_manager, + new_share, + clock, )?; + + repay_reserve.release()?; + + // both reserves released now + } + + { + // after releasing repay we write into the withdraw reserve + withdraw_reserve.acquire_reload_mut()?; + if withdraw_amount == collateral_deposited_amount { + // if there is a full withdraw here (which can happen on a full liquidation), then the borrow + // attribution value needs to be updated on the reserve. note that we can't depend on + // refresh_obligation to update this correctly because the ObligationCollateral object will be + // deleted after this call. + + withdraw_reserve.attributed_borrow_value = withdraw_reserve + .attributed_borrow_value + .saturating_sub(collateral_market_value); + } + + let new_share = obligation.withdraw(withdraw_amount, collateral_index)?; + + // liq. mining + obligation.user_reward_managers.set_share( + withdraw_reserve.key(), + PositionKind::Deposit, + &mut withdraw_reserve.deposits_pool_reward_manager, + new_share, + clock, + )?; + withdraw_reserve.release()?; + + // both reserves released now } - obligation.repay(settle_amount, liquidity_index)?; - obligation.withdraw(withdraw_amount, collateral_index)?; + // and both reserves are again read only + withdraw_reserve.acquire_reload()?; + repay_reserve.acquire_reload()?; + obligation.last_update.mark_stale(); + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { @@ -2301,14 +2352,17 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( let token_program_id = next_account_info(account_info_iter)?; let clock = &Clock::get()?; + let mut repay_reserve = ReserveBorrow::new(program_id, repay_reserve_info)?; + let mut withdraw_reserve = ReserveBorrow::new(program_id, withdraw_reserve_info)?; + let (withdrawn_collateral_amount, bonus) = _liquidate_obligation( program_id, liquidity_amount, source_liquidity_info, destination_collateral_info, - repay_reserve_info, + &mut repay_reserve, repay_reserve_liquidity_supply_info, - withdraw_reserve_info, + &mut withdraw_reserve, withdraw_reserve_collateral_supply_info, obligation_info, lending_market_info, @@ -2317,9 +2371,10 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( clock, token_program_id, )?; + drop(repay_reserve); - _refresh_reserve_interest(program_id, withdraw_reserve_info, clock)?; - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); + withdraw_reserve.acquire_reload_mut()?; + _refresh_reserve_interest(&mut withdraw_reserve, clock)?; let collateral_exchange_rate = withdraw_reserve.collateral_exchange_rate()?; let max_redeemable_collateral = collateral_exchange_rate .liquidity_to_collateral(withdraw_reserve.liquidity.available_amount)?; @@ -2331,7 +2386,7 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( withdraw_collateral_amount, destination_collateral_info, destination_liquidity_info, - withdraw_reserve_info, + &mut withdraw_reserve, withdraw_reserve_collateral_mint_info, withdraw_reserve_liquidity_supply_info, lending_market_info, @@ -2341,7 +2396,6 @@ fn process_liquidate_obligation_and_redeem_reserve_collateral( token_program_id, false, )?; - let withdraw_reserve = Box::new(Reserve::unpack(&withdraw_reserve_info.data.borrow())?); if &withdraw_reserve.config.fee_receiver != withdraw_reserve_liquidity_fee_receiver_info.key { msg!("Withdraw reserve liquidity fee receiver does not match the reserve liquidity fee receiver provided"); @@ -2384,12 +2438,14 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( let clock = &Clock::get()?; let token_program_id = next_account_info(account_info_iter)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + let liquidity_amount = _withdraw_obligation_collateral( program_id, collateral_amount, reserve_collateral_info, user_collateral_info, - reserve_info, + &mut reserve, obligation_info, lending_market_info, lending_market_authority_info, @@ -2405,7 +2461,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( liquidity_amount, user_collateral_info, user_liquidity_info, - reserve_info, + &mut reserve, reserve_collateral_mint_info, reserve_liquidity_supply_info, lending_market_info, @@ -2415,6 +2471,7 @@ fn process_withdraw_obligation_collateral_and_redeem_reserve_liquidity( token_program_id, true, )?; + Ok(()) } @@ -2435,15 +2492,8 @@ fn process_update_reserve_config( let pyth_price_info = next_account_info(account_info_iter)?; let switchboard_feed_info = next_account_info(account_info_iter)?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!( - "Reserve provided is not owned by the lending program {} != {}", - &reserve_info.owner.to_string(), - &program_id.to_string(), - ); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2555,7 +2605,6 @@ fn process_update_reserve_config( } reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; Ok(()) } @@ -2570,15 +2619,7 @@ fn process_redeem_fees(program_id: &Pubkey, accounts: &[AccountInfo]) -> Program let token_program_id = next_account_info(account_info_iter)?; let clock = &Clock::get()?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!( - "Reserve provided is not owned by the lending program {} != {}", - &reserve_info.owner.to_string(), - &program_id.to_string(), - ); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; if &reserve.config.fee_receiver != reserve_liquidity_fee_receiver_info.key { msg!("Reserve liquidity fee receiver does not match the reserve liquidity fee receiver provided"); @@ -2626,7 +2667,6 @@ fn process_redeem_fees(program_id: &Pubkey, accounts: &[AccountInfo]) -> Program reserve.liquidity.redeem_fees(withdraw_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: reserve_supply_liquidity_info.clone(), @@ -2655,18 +2695,21 @@ fn process_flash_borrow_reserve_liquidity( let token_program_id = next_account_info(account_info_iter)?; let clock = Clock::get()?; - _refresh_reserve_interest(program_id, reserve_info, &clock)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + + _refresh_reserve_interest(&mut reserve, &clock)?; _flash_borrow_reserve_liquidity( program_id, liquidity_amount, source_liquidity_info, destination_liquidity_info, - reserve_info, + &mut reserve, lending_market_info, lending_market_authority_info, sysvar_info, token_program_id, )?; + Ok(()) } @@ -2676,7 +2719,7 @@ fn _flash_borrow_reserve_liquidity<'a>( liquidity_amount: u64, source_liquidity_info: &AccountInfo<'a>, destination_liquidity_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, lending_market_info: &AccountInfo<'a>, lending_market_authority_info: &AccountInfo<'a>, sysvar_info: &AccountInfo<'a>, @@ -2691,11 +2734,7 @@ fn _flash_borrow_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2774,7 +2813,7 @@ fn _flash_borrow_reserve_liquidity<'a>( msg!("Multiple flash repays not allowed"); return Err(LendingError::MultipleFlashBorrows.into()); } - if ixn.accounts[4].pubkey != *reserve_info.key { + if ixn.accounts[4].pubkey != reserve.key() { msg!("Invalid reserve account on flash repay"); return Err(LendingError::InvalidFlashRepay.into()); } @@ -2804,7 +2843,6 @@ fn _flash_borrow_reserve_liquidity<'a>( reserve.liquidity.borrow(Decimal::from(liquidity_amount))?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -2835,6 +2873,8 @@ fn process_flash_repay_reserve_liquidity( let sysvar_info = next_account_info(account_info_iter)?; let token_program_id = next_account_info(account_info_iter)?; + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + _flash_repay_reserve_liquidity( program_id, liquidity_amount, @@ -2843,12 +2883,13 @@ fn process_flash_repay_reserve_liquidity( destination_liquidity_info, reserve_liquidity_fee_receiver_info, host_fee_receiver_info, - reserve_info, + &mut reserve, lending_market_info, user_transfer_authority_info, sysvar_info, token_program_id, )?; + Ok(()) } @@ -2861,7 +2902,7 @@ fn _flash_repay_reserve_liquidity<'a>( destination_liquidity_info: &AccountInfo<'a>, reserve_liquidity_fee_receiver_info: &AccountInfo<'a>, host_fee_receiver_info: &AccountInfo<'a>, - reserve_info: &AccountInfo<'a>, + reserve: &mut ReserveBorrow, lending_market_info: &AccountInfo<'a>, user_transfer_authority_info: &AccountInfo<'a>, sysvar_info: &AccountInfo<'a>, @@ -2876,11 +2917,7 @@ fn _flash_repay_reserve_liquidity<'a>( msg!("Lending market token program does not match the token program provided"); return Err(LendingError::InvalidTokenProgram.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -2939,7 +2976,7 @@ fn _flash_repay_reserve_liquidity<'a>( liquidity_amount: borrow_liquidity_amount, } => { // re-check everything here out of paranoia - if ixn.accounts[2].pubkey != *reserve_info.key { + if ixn.accounts[2].pubkey != reserve.key() { msg!("Invalid reserve account on flash repay"); return Err(LendingError::InvalidFlashRepay.into()); } @@ -2959,7 +2996,6 @@ fn _flash_repay_reserve_liquidity<'a>( .liquidity .repay(flash_loan_amount, flash_loan_amount_decimal)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; spl_token_transfer(TokenTransferParams { source: source_liquidity_info.clone(), @@ -3024,11 +3060,8 @@ fn process_forgive_debt( return Err(LendingError::InvalidSigner.into()); } - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; + if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3077,10 +3110,20 @@ fn process_forgive_debt( reserve.liquidity.forgive_debt(forgive_amount)?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - obligation.repay(forgive_amount, liquidity_index)?; + let new_share = obligation.repay(forgive_amount, liquidity_index)?; obligation.last_update.mark_stale(); + + // liq. mining + obligation.user_reward_managers.set_share( + reserve.key(), + PositionKind::Borrow, + &mut reserve.borrows_pool_reward_manager, + new_share, + &Clock::get()?, + )?; + + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -3184,11 +3227,7 @@ pub fn process_set_obligation_closeability_status( return Err(LendingError::InvalidAccountOwner.into()); } - let reserve = Reserve::unpack(&reserve_info.data.borrow())?; - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let reserve = ReserveBorrow::new(program_id, reserve_info)?; if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3240,6 +3279,7 @@ pub fn process_set_obligation_closeability_status( obligation.closeable = closeable; + realloc_obligation_if_necessary(&obligation, obligation_info)?; Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; Ok(()) @@ -3270,12 +3310,8 @@ pub fn process_donate_to_reserve( return Err(LendingError::InvalidTokenProgram.into()); } - if reserve_info.owner != program_id { - msg!("Lending market provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } + let mut reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; - let mut reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); if &reserve.lending_market != lending_market_info.key { msg!("Reserve lending market does not match the lending market provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -3297,7 +3333,7 @@ pub fn process_donate_to_reserve( return Err(LendingError::InvalidAccountInput.into()); } - _refresh_reserve_interest(program_id, reserve_info, clock)?; + _refresh_reserve_interest(&mut reserve, clock)?; reserve.liquidity.donate(liquidity_amount)?; spl_token_transfer(TokenTransferParams { @@ -3310,8 +3346,6 @@ pub fn process_donate_to_reserve( })?; reserve.last_update.mark_stale(); - Reserve::pack(*reserve, &mut reserve_info.data.borrow_mut())?; - Ok(()) } @@ -3542,6 +3576,44 @@ fn is_cpi_call( Ok(false) } +/// Calls realloc on the obligation if the packed size is larger than the +/// underlying buffer. +/// +/// # Important +/// +/// The off-chain client is responsible for making sure the obligation has +/// enough rent to be still rent-exempt. +fn realloc_obligation_if_necessary( + obligation: &Obligation, + obligation_info: &AccountInfo<'_>, +) -> ProgramResult { + let expected_size = obligation.size_in_bytes_when_packed(); + + if expected_size <= obligation_info.data_len() { + return Ok(()); + } + + let current_rent = obligation_info.lamports(); + let new_rent = Rent::get()?.minimum_balance(expected_size); + + if let Some(extra_rent) = new_rent.checked_sub(current_rent) { + msg!("Obligation is missing {} lamports in rent", extra_rent); + return Err(ProgramError::AccountNotRentExempt); + } + + // From the [AccountInfo::realloc] docs: + // + // > Memory used to grow is already zero-initialized upon program entrypoint + // > and re-zeroing it wastes compute units. If within the same call a program + // > reallocs from larger to smaller and back to larger again the new space + // > could contain stale data. Pass true for zero_init in this case, + // > otherwise compute units will be wasted re-zero-initializing. + let zero_init = false; + obligation_info.realloc(expected_size, zero_init)?; + + Ok(()) +} + struct TokenInitializeMintParams<'a: 'b, 'b> { mint: AccountInfo<'a>, rent: AccountInfo<'a>, diff --git a/token-lending/program/src/processor/account_borrow.rs b/token-lending/program/src/processor/account_borrow.rs new file mode 100644 index 00000000000..d3412b39262 --- /dev/null +++ b/token-lending/program/src/processor/account_borrow.rs @@ -0,0 +1,216 @@ +//! # Why do we wrap account data? +//! +//! Previous version of borrow-lending implementation unpacked and then packed +//! the account data several times in a single ix to avoid errors of overwriting +//! data written by other functions. +//! However, this was still fragile as all function calls between unpack and +//! pack would have to be checked to ensure they do not write to the same data. +//! +//! Instead we now have a convention that data access is created in the +//! `process_*` functions and are passed as a reference to other functions. +//! +//! This structure guarantees at runtime that the double write error does not +//! occur while avoiding the cost of unpacking and packing the data. + +use crate::{error::LendingError, state::Reserve}; +use solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, msg, program_error::ProgramError, + program_pack::Pack, pubkey::Pubkey, +}; + +use std::ops::{Deref, DerefMut}; +use std::result::Result; + +/// Wraps around a [Reserve] data and provides runtime borrow semantics. +/// +/// Is either in state of +/// - `release`. The underlying data is not borrowed at all and can be read and +/// written to by other holders of the account info. +/// - `Ref`. The underlying data is borrowed as immutable and can be read by +/// other holders of the account info but not written to. +/// - `RefMut`. The underlying data is borrowed as mutable and can be read and +/// written only via this borrow. +/// +/// # Persistence +/// +/// The data is written to the underlying account buffer when the borrow is +/// done mutably with either [Self::new_mut] or [Self::acquire_reload_mut]. +/// The write happens on [Self::release] or in any function that calls it and on +/// [drop]. +pub(crate) struct ReserveBorrow<'a, 'info> { + info: &'a AccountInfo<'info>, + guard: ReserveDataGuard<'a, 'info>, +} + +enum ReserveDataGuard<'a, 'info> { + Released, + Ref( + #[allow(dead_code)] std::cell::Ref<'a, &'info mut [u8]>, + Box, + ), + RefMut(std::cell::RefMut<'a, &'info mut [u8]>, Box), +} + +enum ReserveDataGuardKind { + Release, + Ref, + RefMut, +} + +impl Drop for ReserveBorrow<'_, '_> { + fn drop(&mut self) { + if let Err(e) = self.release() { + msg!("Failed to release reserve data"); + panic!("{}", e); + } + } +} + +impl Deref for ReserveBorrow<'_, '_> { + type Target = Box; + + fn deref(&self) -> &Self::Target { + match &self.guard { + ReserveDataGuard::Ref(_, inner) => inner, + ReserveDataGuard::RefMut(_, inner) => inner, + ReserveDataGuard::Released => panic!("Reserve data has been released"), + } + } +} + +impl DerefMut for ReserveBorrow<'_, '_> { + fn deref_mut(&mut self) -> &mut Self::Target { + match &mut self.guard { + ReserveDataGuard::RefMut(_, inner) => inner, + ReserveDataGuard::Ref(_, _) => panic!("Reserve data is not mutable"), + ReserveDataGuard::Released => panic!("Reserve data has been released"), + } + } +} + +impl<'a, 'info> ReserveBorrow<'a, 'info> { + /// Creates a new `Ref` guard over the data. + /// + /// Many readers can exist at the same time if no writer is present, + /// otherwise panics. + pub(crate) fn new( + program_id: &Pubkey, + info: &'a AccountInfo<'info>, + ) -> Result { + if info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let data = info.data.borrow(); + let reserve = Box::new(Reserve::unpack(&data)?); + let guard = ReserveDataGuard::Ref(data, reserve); + + Ok(Self { guard, info }) + } + + /// Creates a new `RefMut` guard over the data. + /// + /// Only one writer can exist at a time and no readers, otherwise panics. + pub(crate) fn new_mut( + program_id: &Pubkey, + info: &'a AccountInfo<'info>, + ) -> Result { + if info.owner != program_id { + msg!("Reserve provided is not owned by the lending program"); + return Err(LendingError::InvalidAccountOwner.into()); + } + + let data = info.data.borrow_mut(); + let reserve = Box::new(Reserve::unpack(&data)?); + let guard = ReserveDataGuard::RefMut(data, reserve); + + Ok(Self { guard, info }) + } + + pub(crate) fn key(&self) -> Pubkey { + *self.info.key + } + + /// Explicit version of [drop]ping that panics if the data is not guarded + /// as `RefMut`. + pub(crate) fn commit(self) { + if let ReserveDataGuard::RefMut(_, _) = self.guard { + // drop self + } else { + panic!("Cannot commit a non mutable borrow"); + } + } + + /// Releases the guard over the data. + /// + /// If the data was guarded as `RefMut`, it will be packed back to the + /// account. + pub(crate) fn release(&mut self) -> ProgramResult { + let prev_guard = std::mem::replace(&mut self.guard, ReserveDataGuard::Released); + + if let ReserveDataGuard::RefMut(mut data, inner) = prev_guard { + Reserve::pack(*inner, &mut data)?; + } + + Ok(()) + } + + /// Calls [Self::release] and then creates a new `RefMut` guard. + pub(crate) fn acquire_reload_mut(&mut self) -> ProgramResult { + self.release()?; + + let data_ref = self.info.data.borrow_mut(); + let inner = Reserve::unpack(&data_ref)?; + self.guard = ReserveDataGuard::RefMut(data_ref, Box::new(inner)); + + Ok(()) + } + + /// Calls [Self::release] and then creates a new `RefMut` guard. + pub(crate) fn acquire_reload(&mut self) -> ProgramResult { + self.release()?; + + let data_ref = self.info.data.borrow(); + let inner = Reserve::unpack(&data_ref)?; + self.guard = ReserveDataGuard::Ref(data_ref, Box::new(inner)); + + Ok(()) + } + + /// Releases the guard, calls the given function and returns the guard to + /// the same state it was before the call. + pub(crate) fn while_released( + &mut self, + f: impl FnOnce() -> Result, + ) -> Result { + let prev_guard = ReserveDataGuardKind::from(&self.guard); + self.release()?; + + let res = f(); + + match prev_guard { + ReserveDataGuardKind::Ref => { + self.acquire_reload()?; + } + ReserveDataGuardKind::RefMut => { + self.acquire_reload_mut()?; + } + ReserveDataGuardKind::Release => { + // already released + } + } + + res + } +} + +impl From<&'_ ReserveDataGuard<'_, '_>> for ReserveDataGuardKind { + fn from(guard: &'_ ReserveDataGuard) -> Self { + match guard { + ReserveDataGuard::Released => ReserveDataGuardKind::Release, + ReserveDataGuard::Ref(_, _) => ReserveDataGuardKind::Ref, + ReserveDataGuard::RefMut(_, _) => ReserveDataGuardKind::RefMut, + } + } +} diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 8a2ebf3f9b2..df616720468 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -27,12 +27,11 @@ pub(crate) mod upgrade_reserve; use solana_program::program_pack::Pack; use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; -use solend_sdk::{ - error::LendingError, - state::{LendingMarket, Reserve}, -}; +use solend_sdk::{error::LendingError, state::LendingMarket}; use spl_token::state::Account as TokenAccount; +use super::ReserveBorrow; + /// Unpacks a spl_token [TokenAccount]. fn unpack_token_account(data: &[u8]) -> Result { TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount) @@ -70,15 +69,15 @@ fn reward_vault_authority_seeds<'keys>( /// /// * ✅ `lending_market_owner_info` is a signer /// * ✅ `lending_market_owner_info` matches `lending_market_info` -fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'info>( +fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( program_id: &Pubkey, - reserve_info: &AccountInfo<'info>, + reserve_info: &'a AccountInfo<'info>, reward_mint_info: &AccountInfo<'info>, reward_authority_info: &AccountInfo<'info>, lending_market_info: &AccountInfo<'info>, lending_market_owner_info: &AccountInfo<'info>, token_program_info: &AccountInfo<'info>, -) -> Result<(LendingMarket, Box), ProgramError> { +) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { let (lending_market, reserve) = check_and_unpack_pool_reward_accounts( program_id, reserve_info, @@ -110,19 +109,15 @@ fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'info>( /// * ✅ `lending_market_info` unpacks /// * ✅ `token_program_info` matches `lending_market_info` /// * ✅ `reward_mint_info` belongs to the token program -fn check_and_unpack_pool_reward_accounts<'info>( +fn check_and_unpack_pool_reward_accounts<'a, 'info>( program_id: &Pubkey, - reserve_info: &AccountInfo<'info>, + reserve_info: &'a AccountInfo<'info>, reward_mint_info: &AccountInfo<'info>, reward_authority_info: &AccountInfo<'info>, lending_market_info: &AccountInfo<'info>, token_program_info: &AccountInfo<'info>, -) -> Result<(LendingMarket, Box), ProgramError> { - if reserve_info.owner != program_id { - msg!("Reserve provided is not owned by the lending program"); - return Err(LendingError::InvalidAccountOwner.into()); - } - let reserve = Box::new(Reserve::unpack(&reserve_info.data.borrow())?); +) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { + let reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; if lending_market_info.owner != program_id { msg!("Lending market provided is not owned by the lending program"); diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs index 6f7fe6e5db9..0ea199c0f6a 100644 --- a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -6,7 +6,6 @@ use crate::processor::{ assert_rent_exempt, spl_token_init_account, spl_token_transfer, TokenInitializeAccountParams, TokenTransferParams, }; -use solana_program::program_pack::Pack; use solana_program::{ account_info::{next_account_info, AccountInfo}, clock::Clock, @@ -17,12 +16,11 @@ use solana_program::{ rent::Rent, sysvar::Sysvar, }; -use solend_sdk::{ - error::LendingError, - state::{PositionKind, Reserve}, -}; +use solend_sdk::{error::LendingError, state::PositionKind}; -use super::{check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account}; +use super::{ + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, ReserveBorrow, +}; /// Use [Self::from_unchecked_iter] to validate the accounts except for /// * `reward_token_vault_info` @@ -32,7 +30,7 @@ struct AddPoolRewardAccounts<'a, 'info> { /// ✅ unpacks /// ✅ belongs to `lending_market_info` /// ✅ is writable - reserve_info: &'a AccountInfo<'info>, + _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program reward_mint_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program @@ -62,7 +60,7 @@ struct AddPoolRewardAccounts<'a, 'info> { /// ✅ matches `lending_market_info` token_program_info: &'a AccountInfo<'info>, - reserve: Box, + reserve: ReserveBorrow<'a, 'info>, } /// # Effects @@ -71,7 +69,6 @@ struct AddPoolRewardAccounts<'a, 'info> { /// `reward_token_amount` tokens from the `reward_token_source` account to /// the new reward vault account. /// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. -/// 3. Packs all changes into account buffers. pub(crate) fn process( program_id: &Pubkey, position_kind: PositionKind, @@ -119,13 +116,6 @@ pub(crate) fn process( clock, )?; - // 3. - - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - Ok(()) } @@ -193,7 +183,7 @@ impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { } Ok(Self { - reserve_info, + _reserve_info: reserve_info, reward_mint_info, reward_token_source_info, reward_authority_info, diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs index 875947f1fb2..1a28ed2020b 100644 --- a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs @@ -2,7 +2,6 @@ use crate::processor::liquidity_mining::{ check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, }; use crate::processor::{spl_token_transfer, TokenTransferParams}; -use solana_program::program_pack::Pack; use solana_program::sysvar::Sysvar; use solana_program::{ account_info::{next_account_info, AccountInfo}, @@ -12,12 +11,9 @@ use solana_program::{ program_error::ProgramError, pubkey::Pubkey, }; -use solend_sdk::{ - error::LendingError, - state::{PositionKind, Reserve}, -}; +use solend_sdk::{error::LendingError, state::PositionKind}; -use super::reward_vault_authority_seeds; +use super::{reward_vault_authority_seeds, ReserveBorrow}; /// Use [Self::from_unchecked_iter] to validate the accounts. struct CancelPoolRewardAccounts<'a, 'info> { @@ -25,7 +21,7 @@ struct CancelPoolRewardAccounts<'a, 'info> { /// ✅ unpacks /// ✅ belongs to `lending_market_info` /// ✅ is writable - reserve_info: &'a AccountInfo<'info>, + _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program reward_mint_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program @@ -46,14 +42,13 @@ struct CancelPoolRewardAccounts<'a, 'info> { /// ✅ matches `lending_market_info` token_program_info: &'a AccountInfo<'info>, - reserve: Box, + reserve: ReserveBorrow<'a, 'info>, } /// # Effects /// /// 1. Cancels any further reward emission, effectively setting end time to now. /// 2. Transfers any unallocated rewards to the `reward_token_destination` account. -/// 3. Packs all changes into account buffers. pub(crate) fn process( program_id: &Pubkey, position_kind: PositionKind, @@ -84,19 +79,12 @@ pub(crate) fn process( authority: accounts.reward_authority_info.clone(), authority_signer_seeds: &reward_vault_authority_seeds( accounts.lending_market_info.key, - accounts.reserve_info.key, + &accounts.reserve.key(), accounts.reward_mint_info.key, ), token_program: accounts.token_program_info.clone(), })?; - // 3. - - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - Ok(()) } @@ -151,7 +139,7 @@ impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { } Ok(Self { - reserve_info, + _reserve_info: reserve_info, reward_mint_info, reward_token_destination_info, reward_authority_info, diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index edd8f6653b1..9564a90caad 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -1,5 +1,15 @@ -use crate::processor::{spl_token_transfer, TokenTransferParams}; -use solana_program::program_pack::Pack; +//! Permission-less way to claim allocated user liquidity mining rewards. +//! +//! # Migration +//! +//! Prior to version @2.1.0 there was no concept of liq. mining. +//! That means user shares are going to be 0 even if they have a borrow or +//! deposit. +//! This ix can be used to start tracking obligation's rewards. + +use crate::processor::{ + realloc_obligation_if_necessary, spl_token_transfer, ReserveBorrow, TokenTransferParams, +}; use solana_program::{ account_info::{next_account_info, AccountInfo}, clock::Clock, @@ -9,8 +19,8 @@ use solana_program::{ pubkey::Pubkey, sysvar::Sysvar, }; -use solend_sdk::state::{CreatingNewUserRewardManager, Obligation}; -use solend_sdk::{error::LendingError, state::Reserve}; +use solend_sdk::error::LendingError; +use solend_sdk::state::{Obligation, PositionKind}; use super::{ check_and_unpack_pool_reward_accounts, reward_vault_authority_seeds, unpack_token_account, @@ -32,7 +42,7 @@ struct ClaimUserReward<'a, 'info> { /// ✅ unpacks /// ✅ belongs to `lending_market_info` /// ✅ is writable - reserve_info: &'a AccountInfo<'info>, + _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program reward_mint_info: &'a AccountInfo<'info>, /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` @@ -50,12 +60,12 @@ struct ClaimUserReward<'a, 'info> { token_program_info: &'a AccountInfo<'info>, obligation: Box, - reserve: Box, + reserve: ReserveBorrow<'a, 'info>, } /// # Effects /// -/// 1. Updates the user reward manager with the pool reward manager and accrues rewards +/// 1. Finds the [UserRewardManager] for the reserve and obligation. /// 2. Withdraws all eligible rewards from [UserRewardManager]. /// Eligible rewards are those that match the vault and user has earned any. /// 3. Transfers the withdrawn rewards to the user's token account. @@ -64,29 +74,66 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR let clock = &Clock::get()?; let mut accounts = ClaimUserReward::from_unchecked_iter(program_id, &mut accounts.iter())?; + let reserve_key = accounts.reserve.key(); // 1. - let position_kind = accounts - .obligation - .find_position_kind(*accounts.reserve_info.key)?; + let position_kind = accounts.obligation.find_position_kind(reserve_key)?; let Some(user_reward_manager) = accounts .obligation - .find_user_reward_manager_mut(*accounts.reserve_info.key) + .user_reward_managers + .find_mut(reserve_key, position_kind) else { - // Let's not error if a user has no rewards to claim for this reserve. - // Having this ix idempotent makes cranking easier. + // We've checked that the obligation associates this reserve but it's + // not in the user reward managers yet. + // This means that the obligation hasn't been migrated to track the + // pool reward manager. + // + // We'll upgrade it here. + + let reserve_key = accounts.reserve.key(); + + let (pool_reward_manager, migrated_share) = match position_kind { + PositionKind::Borrow => { + let share = accounts + .obligation + .find_liquidity_in_borrows(reserve_key)? + .0 + .liability_shares()?; + + (&mut accounts.reserve.borrows_pool_reward_manager, share) + } + PositionKind::Deposit => { + let share = accounts + .obligation + .find_collateral_in_deposits(reserve_key)? + .0 + .deposited_amount; + + (&mut accounts.reserve.deposits_pool_reward_manager, share) + } + }; + + accounts.obligation.user_reward_managers.set_share( + reserve_key, + position_kind, + pool_reward_manager, + migrated_share, + clock, + )?; + + realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; + Obligation::pack( + *accounts.obligation, + &mut accounts.obligation_info.data.borrow_mut(), + )?; + return Ok(()); }; let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - // Syncs the pool reward manager with the user manager and accrues rewards. - // If we wanted to optimize CU usage then we could make a dedicated update - // function only for claiming rewards to avoid iterating twice over the rewards. - user_reward_manager.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; - // 2. let total_reward_amount = user_reward_manager.claim_rewards( @@ -104,7 +151,7 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR authority: accounts.reward_authority_info.clone(), authority_signer_seeds: &reward_vault_authority_seeds( accounts.lending_market_info.key, - accounts.reserve_info.key, + &reserve_key, accounts.reward_mint_info.key, ), token_program: accounts.token_program_info.clone(), @@ -112,15 +159,13 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR // 4. + realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; Obligation::pack( *accounts.obligation, &mut accounts.obligation_info.data.borrow_mut(), )?; - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; + // reserve is packed on drop Ok(()) } @@ -215,7 +260,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { Ok(Self { obligation_info, obligation_owner_token_account_info, - reserve_info, + _reserve_info: reserve_info, reward_mint_info, reward_authority_info, reward_token_vault_info, diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs index 34d6bf4a769..a0b29a15afb 100644 --- a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -5,7 +5,6 @@ //! //! The claim ix is permission-less and therefore it can be cranked. -use solana_program::program_pack::Pack; use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, @@ -13,10 +12,7 @@ use solana_program::{ program_error::ProgramError, pubkey::Pubkey, }; -use solend_sdk::{ - error::LendingError, - state::{PositionKind, Reserve}, -}; +use solend_sdk::{error::LendingError, state::PositionKind}; use spl_token::state::Account as TokenAccount; use crate::processor::{ @@ -25,7 +21,7 @@ use crate::processor::{ use super::{ check_and_unpack_pool_reward_accounts_for_admin_ixs, reward_vault_authority_seeds, - unpack_token_account, + unpack_token_account, ReserveBorrow, }; /// Use [Self::from_unchecked_iter] to validate the accounts. @@ -57,7 +53,7 @@ struct ClosePoolRewardAccounts<'a, 'info> { /// ✅ matches `lending_market_info` token_program_info: &'a AccountInfo<'info>, - reserve: Box, + reserve: ReserveBorrow<'a, 'info>, reward_token_vault: TokenAccount, } @@ -66,7 +62,6 @@ struct ClosePoolRewardAccounts<'a, 'info> { /// 1. Closes reward in the [Reserve] account if all users have claimed. /// 2. Transfers dust to the `reward_token_destination` account. /// 3. Closes reward vault token account. -/// 3. Packs all changes into account buffers. pub(crate) fn process( program_id: &Pubkey, position_kind: PositionKind, @@ -116,13 +111,6 @@ pub(crate) fn process( token_program: accounts.token_program_info.clone(), })?; - // 4. - - Reserve::pack( - *accounts.reserve, - &mut accounts.reserve_info.data.borrow_mut(), - )?; - Ok(()) } diff --git a/token-lending/program/tests/attributed_borrows.rs b/token-lending/program/tests/attributed_borrows.rs index 39a7bafd28d..26517e051af 100644 --- a/token-lending/program/tests/attributed_borrows.rs +++ b/token-lending/program/tests/attributed_borrows.rs @@ -4,6 +4,7 @@ use crate::solend_program_test::custom_scenario; use crate::solend_program_test::User; +use pretty_assertions::assert_eq; use solend_program::math::TryDiv; use solana_sdk::instruction::InstructionError; @@ -12,6 +13,7 @@ use solend_program::math::TryAdd; use solend_program::state::LastUpdate; use solend_program::state::Reserve; use solend_sdk::error::LendingError; +use solend_sdk::state::PoolRewardManager; use solend_sdk::state::ReserveLiquidity; use crate::solend_program_test::ObligationArgs; @@ -314,7 +316,7 @@ async fn test_calculations() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is transfer to obligation for realloc, ix 2 is borrow InstructionError::Custom(LendingError::BorrowAttributionLimitExceeded as u32) ) ); @@ -355,6 +357,7 @@ async fn test_calculations() { { let usdc_reserve = reserves[0].account.clone(); let usdc_reserve_post = test.load_account::(reserves[0].pubkey).await; + let expected_usdc_reserve_post = Reserve { last_update: LastUpdate { slot: 1001, @@ -382,6 +385,20 @@ async fn test_calculations() { attributed_borrow_limit_open: 120, ..usdc_reserve.config }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert!( + usdc_reserve.borrows_pool_reward_manager.total_shares + < usdc_reserve_post + .account + .borrows_pool_reward_manager + .total_shares + ); + + 120_000_000 + }, + ..*usdc_reserve.borrows_pool_reward_manager + }), ..usdc_reserve }; assert_eq!(usdc_reserve_post.account, expected_usdc_reserve_post); diff --git a/token-lending/program/tests/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index 881a7bbb90d..02cbba53a6b 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -1,6 +1,7 @@ #![cfg(feature = "test-bpf")] use crate::helpers::solend_program_test::*; +use pretty_assertions::assert_eq; use solana_program::pubkey::Pubkey; use solana_sdk::signature::Signer; @@ -216,7 +217,20 @@ async fn test_success() { usdc_reserve_post, ); - let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + let mut wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; + + { + // let's test liq. mining separately bcs of clock time + + let borrows_manager = &wsol_reserve_post.account.borrows_pool_reward_manager; + + assert_eq!(borrows_manager.total_shares, 4000000400); + assert_ne!(borrows_manager.last_update_time_secs, 0); + + wsol_reserve_post.account.borrows_pool_reward_manager = + wsol_reserve.account.borrows_pool_reward_manager.clone(); + } + let expected_wsol_reserve_post = Reserve { last_update: LastUpdate { slot: 1000, @@ -238,11 +252,7 @@ async fn test_success() { ..wsol_reserve.account }; - assert_eq!( - wsol_reserve_post.account, expected_wsol_reserve_post, - "{:#?} {:#?}", - wsol_reserve_post, expected_wsol_reserve_post - ); + assert_eq!(wsol_reserve_post.account, expected_wsol_reserve_post); let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( @@ -271,10 +281,30 @@ async fn test_success() { unweighted_borrowed_value: borrow_value, allowed_borrow_value: Decimal::from(50u64), unhealthy_borrow_value: Decimal::from(55u64), + user_reward_managers: { + // clock value remains the same + let last_update_time_secs = + obligation.account.user_reward_managers[0].last_update_time_secs; + + UserRewardManagers(vec![ + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 100000000, + last_update_time_secs, + rewards: Vec::new(), + }, + UserRewardManager { + reserve: wsol_reserve.pubkey, + position_kind: PositionKind::Borrow, + share: 4000000400, + last_update_time_secs, + rewards: Vec::new(), + }, + ]) + }, ..obligation.account }, - "{:#?}", - obligation_post.account ); } @@ -379,7 +409,7 @@ async fn test_fail_borrow_over_reserve_borrow_limit() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::InvalidAmount as u32) ) ); @@ -450,7 +480,7 @@ async fn test_fail_reserve_borrow_rate_limit_exceeded() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); @@ -476,7 +506,7 @@ async fn test_fail_reserve_borrow_rate_limit_exceeded() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, // ix 0 is CU budget, ix 1 is obligation realloc, ix 2 is borrow InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); diff --git a/token-lending/program/tests/borrow_weight.rs b/token-lending/program/tests/borrow_weight.rs index 9cbeadaedff..3e417caf628 100644 --- a/token-lending/program/tests/borrow_weight.rs +++ b/token-lending/program/tests/borrow_weight.rs @@ -340,7 +340,7 @@ async fn test_liquidation() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); diff --git a/token-lending/program/tests/deposit_obligation_collateral.rs b/token-lending/program/tests/deposit_obligation_collateral.rs index 8992464dcbb..959630a5cd4 100644 --- a/token-lending/program/tests/deposit_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_obligation_collateral.rs @@ -9,12 +9,16 @@ use helpers::solend_program_test::{ }; use helpers::test_reserve_config; +use pretty_assertions::assert_eq; use solana_program::instruction::InstructionError; use solana_program_test::*; use solana_sdk::signature::Keypair; use solana_sdk::transaction::TransactionError; use solend_program::math::Decimal; -use solend_program::state::{LastUpdate, LendingMarket, Obligation, ObligationCollateral, Reserve}; +use solend_program::state::{ + LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, + Reserve, UserRewardManager, UserRewardManagers, +}; async fn setup() -> ( SolendProgramTest, @@ -47,8 +51,10 @@ async fn test_success() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user]).await; + let deposit_amount = 1_000_000u64; + lending_market - .deposit_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, 1_000_000) + .deposit_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, deposit_amount) .await .expect("This should succeed"); @@ -61,12 +67,12 @@ async fn test_success() { .get_account(&usdc_reserve.account.collateral.mint_pubkey) .unwrap(), mint: usdc_reserve.account.collateral.mint_pubkey, - diff: -1_000_000, + diff: -(deposit_amount as i128), }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, ]); @@ -77,8 +83,24 @@ async fn test_success() { let lending_market_post = test.load_account(lending_market.pubkey).await; assert_eq!(lending_market, lending_market_post); - let usdc_reserve_post = test.load_account(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve, usdc_reserve_post); + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let last_update_time_secs = usdc_reserve_post + .account + .deposits_pool_reward_manager + .last_update_time_secs; + assert_ne!(last_update_time_secs, 0); + + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: deposit_amount, + last_update_time_secs, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( @@ -90,10 +112,17 @@ async fn test_success() { }, deposits: vec![ObligationCollateral { deposit_reserve: usdc_reserve.pubkey, - deposited_amount: 1_000_000, + deposited_amount: deposit_amount, market_value: Decimal::zero(), // this field only gets updated on a refresh attributed_borrow_value: Decimal::zero() }], + user_reward_managers: UserRewardManagers(vec![UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: deposit_amount, + last_update_time_secs, + rewards: Vec::new(), + }]), ..obligation.account } ); @@ -110,11 +139,13 @@ async fn test_fail_deposit_too_much() { .unwrap() .unwrap(); + // ix 0 is CU budget, ix 1 is transfer to obligation for realloc, ix 2 is deposit + const EXPECTED_IX: u8 = 2; match res { // InsufficientFunds - TransactionError::InstructionError(1, InstructionError::Custom(1)) => (), + TransactionError::InstructionError(EXPECTED_IX, InstructionError::Custom(1)) => (), // LendingError::TokenTransferFailed - TransactionError::InstructionError(1, InstructionError::Custom(17)) => (), + TransactionError::InstructionError(EXPECTED_IX, InstructionError::Custom(17)) => (), e => panic!("unexpected error: {:#?}", e), }; } diff --git a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs index 6b6779fc9b9..01fc216d29b 100644 --- a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs @@ -3,6 +3,7 @@ mod helpers; use crate::solend_program_test::MintSupplyChange; +use pretty_assertions::assert_eq; use std::collections::HashSet; use helpers::solend_program_test::{ @@ -14,8 +15,8 @@ use solana_sdk::signature::Keypair; use solend_program::math::Decimal; use solend_program::state::{ - LastUpdate, LendingMarket, Obligation, ObligationCollateral, Reserve, ReserveCollateral, - ReserveLiquidity, + LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, + Reserve, ReserveCollateral, ReserveLiquidity, UserRewardManager, UserRewardManagers, }; async fn setup() -> ( @@ -44,6 +45,8 @@ async fn test_success() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user]).await; + let deposit_amount = 1_000_000; + // deposit lending_market .deposit_reserve_liquidity_and_obligation_collateral( @@ -51,7 +54,7 @@ async fn test_success() { &usdc_reserve, &obligation, &user, - 1_000_000, + deposit_amount, ) .await .expect("this should succeed"); @@ -66,31 +69,27 @@ async fn test_success() { TokenBalanceChange { token_account: user.get_account(&usdc_mint::id()).unwrap(), mint: usdc_mint::id(), - diff: -1_000_000, + diff: -(deposit_amount as i128), }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, TokenBalanceChange { token_account: usdc_reserve.account.liquidity.supply_pubkey, mint: usdc_reserve.account.liquidity.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, }, ]), - "{:#?}", - token_balance_changes ); assert_eq!( mint_supply_changes, HashSet::from([MintSupplyChange { mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: deposit_amount as _, },]), - "{:#?}", - mint_supply_changes ); // check program state @@ -100,21 +99,33 @@ async fn test_success() { assert_eq!(lending_market.account, lending_market_post.account); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let last_update_time_secs = usdc_reserve_post + .account + .deposits_pool_reward_manager + .last_update_time_secs; + assert_ne!(last_update_time_secs, 0); + assert_eq!( usdc_reserve_post.account, Reserve { last_update: LastUpdate { slot: 1001, - stale: false, + stale: true, }, liquidity: ReserveLiquidity { - available_amount: usdc_reserve.account.liquidity.available_amount + 1_000_000, + available_amount: usdc_reserve.account.liquidity.available_amount + deposit_amount, ..usdc_reserve.account.liquidity }, collateral: ReserveCollateral { - mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + 1_000_000, + mint_total_supply: usdc_reserve.account.collateral.mint_total_supply + + deposit_amount, ..usdc_reserve.account.collateral }, + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: deposit_amount, + last_update_time_secs, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), ..usdc_reserve.account } ); @@ -134,6 +145,13 @@ async fn test_success() { attributed_borrow_value: Decimal::zero() }] .to_vec(), + user_reward_managers: UserRewardManagers(vec![UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: deposit_amount, + last_update_time_secs: last_update_time_secs, + rewards: Vec::new(), + }]), ..obligation.account } ); diff --git a/token-lending/program/tests/forgive_debt.rs b/token-lending/program/tests/forgive_debt.rs index 1d6360d301f..af1685d0778 100644 --- a/token-lending/program/tests/forgive_debt.rs +++ b/token-lending/program/tests/forgive_debt.rs @@ -10,6 +10,7 @@ use solana_sdk::instruction::Instruction; use solana_sdk::pubkey::Pubkey; use solana_sdk::signer::Signer; +use pretty_assertions::assert_eq; use std::collections::HashSet; use solend_sdk::instruction::LendingInstruction; @@ -121,7 +122,7 @@ async fn test_forgive_debt_success_easy() { assert_eq!( err, TransactionError::InstructionError( - 3, + 4, InstructionError::Custom(LendingError::InvalidAccountInput as u32) ) ); @@ -202,6 +203,10 @@ async fn test_forgive_debt_success_easy() { + wsol_reserve.account.liquidity.available_amount, ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: 0, // liquidated everything + ..*wsol_reserve.account.borrows_pool_reward_manager.clone() + }), ..wsol_reserve.account.clone() } ); @@ -308,7 +313,7 @@ async fn test_forgive_debt_fail_invalid_signer() { assert_eq!( err, TransactionError::InstructionError( - 3, + 4, InstructionError::Custom(LendingError::InvalidMarketOwner as u32) ) ); diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index b7979450c98..323dcdcc550 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -58,24 +58,31 @@ use super::mock_pyth::{init, set_price}; use super::mock_pyth_pull::{init as init_pull, set_price as set_price_pull}; mod cu_budgets { - pub(super) const INIT_OBLIGATION: u32 = 5_001; + pub(super) const INIT_OBLIGATION: u32 = 10_001; pub(super) const DEPOSIT_OBLIGATION_COLLATERAL: u32 = 70_002; pub(super) const REFRESH_RESERVE: u32 = 2_000_003; pub(super) const REFRESH_OBLIGATION: u32 = 1_000_004; - pub(super) const BORROW_OBLIGATION_LIQUIDITY: u32 = 140_005; + pub(super) const BORROW_OBLIGATION_LIQUIDITY: u32 = 180_005; pub(super) const REPAY_OBLIGATION_LIQUIDITY: u32 = 70_006; pub(super) const REDEEM_FEES: u32 = 80_007; - pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_008; + pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 230_008; pub(super) const WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_009; - pub(super) const WITHDRAW_OBLIGATION_COLLATERAL: u32 = 100_010; + pub(super) const WITHDRAW_OBLIGATION_COLLATERAL: u32 = 130_010; pub(super) const INIT_RESERVE: u32 = 90_011; pub(super) const DEPOSIT: u32 = 70_012; pub(super) const DONATE_TO_RESERVE: u32 = 50_013; - pub(super) const UPDATE_RESERVE_CONFIG: u32 = 25_014; + pub(super) const UPDATE_RESERVE_CONFIG: u32 = 30_014; pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; - pub(super) const REDEEM: u32 = 130_016; + pub(super) const REDEEM: u32 = 90_016; } +/// This is at most how many bytes can an obligation grow. +/// An obligation grows dynamically as needed when new rewards are being tracked. +/// These tests don't need to care about correctly transferring just the amount +/// needed, we'll just transfer lamports to cover the rent of the largest +/// possible obligation there can be. +const OBLIGATION_EXTRA_SIZE: usize = Obligation::MAX_LEN - Obligation::MIN_LEN; + pub struct SolendProgramTest { pub context: ProgramTestContext, rent: Rent, @@ -968,6 +975,11 @@ impl Info { ComputeBudgetInstruction::set_compute_unit_limit( cu_budgets::DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL, ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), deposit_reserve_liquidity_and_obligation_collateral( solend_program::id(), liquidity_amount, @@ -1071,6 +1083,11 @@ impl Info { ComputeBudgetInstruction::set_compute_unit_limit( cu_budgets::DEPOSIT_OBLIGATION_COLLATERAL, ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), deposit_obligation_collateral( solend_program::id(), collateral_amount, @@ -1166,6 +1183,12 @@ impl Info { r }; + instructions.push(system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + )); + instructions.push(refresh_obligation( solend_program::id(), obligation.pubkey, @@ -1219,9 +1242,16 @@ impl Info { .await; test.process_transaction(&refresh_ixs, None).await.unwrap(); - let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_limit( - cu_budgets::BORROW_OBLIGATION_LIQUIDITY, - )]; + let mut instructions = vec![ + ComputeBudgetInstruction::set_compute_unit_limit( + cu_budgets::BORROW_OBLIGATION_LIQUIDITY, + ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), + ]; instructions.push(borrow_obligation_liquidity( solend_program::id(), liquidity_amount, @@ -1320,6 +1350,11 @@ impl Info { ComputeBudgetInstruction::set_compute_unit_limit( cu_budgets::LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL, ), + system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + ), liquidate_obligation_and_redeem_reserve_collateral( solend_program::id(), liquidity_amount, @@ -1359,6 +1394,11 @@ impl Info { .build_refresh_instructions(test, obligation, None) .await; + instructions.push(system_instruction::transfer( + &test.context.payer.pubkey(), + &obligation.pubkey, + Rent::minimum_balance(&Rent::default(), OBLIGATION_EXTRA_SIZE), + )); instructions.push(liquidate_obligation( solend_program::id(), liquidity_amount, diff --git a/token-lending/program/tests/init_obligation.rs b/token-lending/program/tests/init_obligation.rs index 1747113fa15..ecb32f4ac8e 100644 --- a/token-lending/program/tests/init_obligation.rs +++ b/token-lending/program/tests/init_obligation.rs @@ -54,7 +54,7 @@ async fn test_success() { super_unhealthy_borrow_value: Decimal::zero(), borrowing_isolated_asset: false, closeable: false, - user_reward_managers: Vec::new(), + user_reward_managers: Default::default(), } ); } diff --git a/token-lending/program/tests/isolated_tier_assets.rs b/token-lending/program/tests/isolated_tier_assets.rs index 714e257fa6b..4fd5c411eef 100644 --- a/token-lending/program/tests/isolated_tier_assets.rs +++ b/token-lending/program/tests/isolated_tier_assets.rs @@ -1,5 +1,7 @@ #![cfg(feature = "test-bpf")] +use pretty_assertions::assert_eq; + use crate::solend_program_test::custom_scenario; use solend_program::state::ObligationCollateral; @@ -15,7 +17,10 @@ use solend_sdk::math::Decimal; use solend_program::state::LastUpdate; use solend_program::state::ReserveType; -use solend_program::state::{Obligation, ObligationLiquidity, ReserveConfig}; +use solend_program::state::{ + Obligation, ObligationLiquidity, PositionKind, ReserveConfig, UserRewardManager, + UserRewardManagers, +}; use solend_sdk::state::ReserveFees; mod helpers; @@ -85,6 +90,10 @@ async fn test_refresh_obligation() { .iter() .find(|r| r.account.liquidity.mint_pubkey == wsol_mint::id()) .unwrap(); + let usdc_reserve = reserves + .iter() + .find(|r| r.account.liquidity.mint_pubkey == usdc_mint::id()) + .unwrap(); // borrow isolated tier asset lending_market @@ -106,6 +115,10 @@ async fn test_refresh_obligation() { let obligation_post = test.load_obligation(obligations[0].pubkey).await; + let last_update_time_secs = + obligation_post.account.user_reward_managers[0].last_update_time_secs; + assert_ne!(last_update_time_secs, 0,); + assert_eq!( obligation_post.account, Obligation { @@ -127,6 +140,22 @@ async fn test_refresh_obligation() { unweighted_borrowed_value: Decimal::from(10u64), borrowed_value_upper_bound: Decimal::from(10u64), borrowing_isolated_asset: true, + user_reward_managers: UserRewardManagers(vec![ + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 100000000, + last_update_time_secs, + rewards: Vec::new(), + }, + UserRewardManager { + reserve: wsol_reserve.pubkey, + position_kind: PositionKind::Borrow, + share: 1000000000, + last_update_time_secs, + rewards: Vec::new(), + }, + ],), ..obligations[0].account.clone() } ); @@ -287,7 +316,7 @@ async fn borrow_isolated_asset_invalid() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); @@ -381,7 +410,7 @@ async fn borrow_regular_asset_invalid() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); @@ -485,7 +514,7 @@ async fn invalid_borrow_due_to_reserve_config_change() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::IsolatedTierAssetViolation as u32) ) ); diff --git a/token-lending/program/tests/liquidate_obligation.rs b/token-lending/program/tests/liquidate_obligation.rs index e583f55b702..b58a1fb0384 100644 --- a/token-lending/program/tests/liquidate_obligation.rs +++ b/token-lending/program/tests/liquidate_obligation.rs @@ -33,7 +33,7 @@ async fn test_fail_deprecated() { assert_eq!( res, TransactionError::InstructionError( - 3, + 5, InstructionError::Custom(LendingError::DeprecatedInstruction as u32) ) ); diff --git a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs index 75eedf36ffa..0edcdfa1523 100644 --- a/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs +++ b/token-lending/program/tests/liquidate_obligation_and_redeem_collateral.rs @@ -30,11 +30,13 @@ use solana_sdk::signature::Keypair; use solend_program::math::Decimal; use solend_program::state::LendingMarket; use solend_program::state::Obligation; +use solend_program::state::PoolRewardManager; use solend_program::state::Reserve; use solend_program::state::ReserveCollateral; use solend_program::state::ReserveLiquidity; use solend_program::state::LIQUIDATION_CLOSE_FACTOR; +use pretty_assertions::assert_eq; use std::collections::HashSet; #[tokio::test] @@ -166,9 +168,18 @@ async fn test_success_new() { assert_eq!(lending_market_post.account, lending_market.account); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + let expected_usdc_reserve_post_total_shares = usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - expected_usdc_withdrawn * FRACTIONAL_TO_USDC; assert_eq!( usdc_reserve_post.account, Reserve { + last_update: LastUpdate { + stale: true, + ..usdc_reserve.account.last_update + }, liquidity: ReserveLiquidity { available_amount: usdc_reserve.account.liquidity.available_amount - expected_usdc_withdrawn * FRACTIONAL_TO_USDC, @@ -180,14 +191,23 @@ async fn test_success_new() { ..usdc_reserve.account.collateral }, attributed_borrow_value: Decimal::from(55000u64), + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: expected_usdc_reserve_post_total_shares, + ..*usdc_reserve.account.deposits_pool_reward_manager.clone() + }), ..usdc_reserve.account } ); + let expected_wsol_reserve_post_total_shares = 8000000000; let wsol_reserve_post = test.load_account::(wsol_reserve.pubkey).await; assert_eq!( wsol_reserve_post.account, Reserve { + last_update: LastUpdate { + stale: true, + ..wsol_reserve.account.last_update + }, liquidity: ReserveLiquidity { available_amount: wsol_reserve.account.liquidity.available_amount + expected_borrow_repaid * LAMPORTS_TO_SOL, @@ -201,10 +221,26 @@ async fn test_success_new() { smoothed_market_price: Decimal::from(5500u64), ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert_eq!( + wsol_reserve + .account + .borrows_pool_reward_manager + .total_shares, + 10000000000 + ); + + expected_wsol_reserve_post_total_shares + }, + ..*wsol_reserve.account.borrows_pool_reward_manager.clone() + }), ..wsol_reserve.account } ); + let deposit_reserve = usdc_reserve.pubkey; + let borrow_reserve = wsol_reserve.pubkey; let obligation_post = test.load_obligation(obligation.pubkey).await; assert_eq!( obligation_post.account, @@ -214,7 +250,7 @@ async fn test_success_new() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: (100_000 - expected_usdc_withdrawn) * FRACTIONAL_TO_USDC, market_value: Decimal::from(100_000u64), // old value attributed_borrow_value: obligation_post.account.deposits[0] @@ -222,7 +258,7 @@ async fn test_success_new() { }] .to_vec(), borrows: [ObligationLiquidity { - borrow_reserve: wsol_reserve.pubkey, + borrow_reserve, cumulative_borrow_rate_wads: Decimal::one(), borrowed_amount_wads: Decimal::from(10 * LAMPORTS_TO_SOL) .try_sub(Decimal::from(expected_borrow_repaid * LAMPORTS_TO_SOL)) @@ -236,6 +272,21 @@ async fn test_success_new() { borrowed_value_upper_bound: Decimal::from(55_000u64), allowed_borrow_value: Decimal::from(50_000u64), unhealthy_borrow_value: Decimal::from(55_000u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = expected_usdc_reserve_post_total_shares; + + og.iter_mut() + .find(|m| m.reserve == borrow_reserve) + .unwrap() + .share = expected_wsol_reserve_post_total_shares; + + og + }, ..obligation.account } ); @@ -322,7 +373,7 @@ async fn test_whitelisting_liquidator() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::NotWhitelistedLiquidator as u32) ) ); @@ -648,7 +699,7 @@ async fn test_liquidity_ordering() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::InvalidAccountInput as u32) ) ); diff --git a/token-lending/program/tests/outflow_rate_limits.rs b/token-lending/program/tests/outflow_rate_limits.rs index f42dba03f28..66e47fc7d1d 100644 --- a/token-lending/program/tests/outflow_rate_limits.rs +++ b/token-lending/program/tests/outflow_rate_limits.rs @@ -169,7 +169,7 @@ async fn test_outflow_reserve() { assert_eq!( res, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::OutflowRateLimitExceeded as u32) ) ); diff --git a/token-lending/program/tests/repay_obligation_liquidity.rs b/token-lending/program/tests/repay_obligation_liquidity.rs index 989da4fab21..24686f4d4e5 100644 --- a/token-lending/program/tests/repay_obligation_liquidity.rs +++ b/token-lending/program/tests/repay_obligation_liquidity.rs @@ -3,6 +3,7 @@ mod helpers; use crate::solend_program_test::scenario_1; +use pretty_assertions::assert_eq; use std::collections::HashSet; use helpers::solend_program_test::{BalanceChecker, TokenBalanceChange}; @@ -14,7 +15,7 @@ use solend_program::math::TryDiv; use solend_program::state::{LastUpdate, ObligationLiquidity, ReserveLiquidity, SLOTS_PER_YEAR}; use solend_program::{ math::{Decimal, TryAdd, TryMul, TrySub}, - state::{Obligation, Reserve}, + state::{Obligation, PoolRewardManager, Reserve}, }; #[tokio::test] @@ -73,6 +74,7 @@ async fn test_success() { .try_sub(Decimal::from(10 * LAMPORTS_TO_SOL)) .unwrap(); + let expected_wsol_reserve_post_borrow_total_shares = 47; assert_eq!( wsol_reserve_post.account, Reserve { @@ -86,11 +88,26 @@ async fn test_success() { cumulative_borrow_rate_wads: new_cumulative_borrow_rate, ..wsol_reserve.account.liquidity }, + borrows_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: { + assert_eq!( + wsol_reserve + .account + .borrows_pool_reward_manager + .total_shares, + 10 * LAMPORTS_PER_SOL, + ); + + expected_wsol_reserve_post_borrow_total_shares + }, + ..*wsol_reserve.account.borrows_pool_reward_manager + }), ..wsol_reserve.account } ); let obligation_post = test.load_obligation(obligation.pubkey).await; + let borrow_reserve = wsol_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -100,12 +117,22 @@ async fn test_success() { stale: true }, borrows: [ObligationLiquidity { - borrow_reserve: wsol_reserve.pubkey, + borrow_reserve, cumulative_borrow_rate_wads: new_cumulative_borrow_rate, borrowed_amount_wads: new_borrowed_amount_wads, ..obligation.account.borrows[0] }] .to_vec(), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == borrow_reserve) + .unwrap() + .share = expected_wsol_reserve_post_borrow_total_shares; + + og + }, ..obligation.account } ); diff --git a/token-lending/program/tests/two_prices.rs b/token-lending/program/tests/two_prices.rs index 463b562fb06..809c7a25219 100644 --- a/token-lending/program/tests/two_prices.rs +++ b/token-lending/program/tests/two_prices.rs @@ -144,7 +144,7 @@ async fn test_borrow() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::BorrowTooLarge as u32) ) ); @@ -376,7 +376,7 @@ async fn test_liquidation_doesnt_use_smoothed_price() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); @@ -412,7 +412,7 @@ async fn test_liquidation_doesnt_use_smoothed_price() { assert_eq!( err, TransactionError::InstructionError( - 1, + 2, InstructionError::Custom(LendingError::ObligationHealthy as u32) ) ); diff --git a/token-lending/program/tests/withdraw_obligation_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral.rs index faf8073df32..9109d4ea4b3 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral.rs @@ -9,7 +9,9 @@ use solend_sdk::math::Decimal; use solana_program_test::*; +use pretty_assertions::assert_eq; use solend_program::state::{LastUpdate, Obligation, ObligationCollateral, Reserve}; +use solend_sdk::state::PoolRewardManager; use std::collections::HashSet; use std::u64; @@ -21,8 +23,16 @@ async fn test_success_withdraw_fixed_amount() { let balance_checker = BalanceChecker::start(&mut test, &[&usdc_reserve, &user, &wsol_reserve]).await; + let withdraw_amount = 1_000_000; + lending_market - .withdraw_obligation_collateral(&mut test, &usdc_reserve, &obligation, &user, 1_000_000) + .withdraw_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + withdraw_amount, + ) .await .unwrap(); @@ -34,21 +44,35 @@ async fn test_success_withdraw_fixed_amount() { .get_account(&usdc_reserve.account.collateral.mint_pubkey) .unwrap(), mint: usdc_reserve.account.collateral.mint_pubkey, - diff: 1_000_000, + diff: withdraw_amount as _, }, TokenBalanceChange { token_account: usdc_reserve.account.collateral.supply_pubkey, mint: usdc_reserve.account.collateral.mint_pubkey, - diff: -1_000_000, + diff: -(withdraw_amount as i128), }, ]); assert_eq!(balance_changes, expected_balance_changes); assert_eq!(mint_supply_changes, HashSet::new()); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve_post.account, usdc_reserve.account); + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -57,13 +81,27 @@ async fn test_success_withdraw_fixed_amount() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, - deposited_amount: 100_000_000_000 - 1_000_000, + deposit_reserve, + deposited_amount: 100_000_000_000 - withdraw_amount, market_value: Decimal::from(99_999u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(99_999u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount; + + og + }, ..obligation.account } ); @@ -111,9 +149,19 @@ async fn test_success_withdraw_max() { assert_eq!(mint_supply_changes, HashSet::new()); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; - assert_eq!(usdc_reserve_post.account, usdc_reserve.account); + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: expected_remaining_collateral, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), + ..usdc_reserve.account + } + ); let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -122,13 +170,23 @@ async fn test_success_withdraw_max() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: expected_remaining_collateral, market_value: Decimal::from(200u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(200u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = expected_remaining_collateral; + + og + }, ..obligation.account } ); diff --git a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs index 3cfc66f072f..dc68a9a4d63 100644 --- a/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs +++ b/token-lending/program/tests/withdraw_obligation_collateral_and_redeem_reserve_collateral.rs @@ -7,6 +7,7 @@ use solend_program::math::TryDiv; mod helpers; use crate::solend_program_test::*; +use pretty_assertions::assert_eq; use solend_sdk::math::Decimal; use solend_sdk::state::ObligationCollateral; use solend_sdk::state::ReserveCollateral; @@ -126,11 +127,20 @@ async fn test_success() { rate_limiter }, + deposits_pool_reward_manager: Box::new(PoolRewardManager { + total_shares: usdc_reserve + .account + .deposits_pool_reward_manager + .total_shares + - withdraw_amount as u64, + ..*usdc_reserve.account.deposits_pool_reward_manager + }), ..usdc_reserve.account } ); let obligation_post = test.load_obligation(obligation.pubkey).await; + let deposit_reserve = usdc_reserve.pubkey; assert_eq!( obligation_post.account, Obligation { @@ -139,13 +149,23 @@ async fn test_success() { stale: true }, deposits: [ObligationCollateral { - deposit_reserve: usdc_reserve.pubkey, + deposit_reserve, deposited_amount: 200 * FRACTIONAL_TO_USDC, market_value: Decimal::from(200u64), ..obligation.account.deposits[0] }] .to_vec(), deposited_value: Decimal::from(200u64), + user_reward_managers: { + let mut og = obligation.account.user_reward_managers.clone(); + + og.iter_mut() + .find(|m| m.reserve == deposit_reserve) + .unwrap() + .share = 200 * FRACTIONAL_TO_USDC; + + og + }, ..obligation.account } ); diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index 7ebce7c8d64..0cc1bc180af 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -23,6 +23,7 @@ uint = "=0.9.1" assert_matches = "1.5.0" base64 = "0.13" log = "0.4.14" +pretty_assertions = "1.4.1" proptest = "1.6" rand = "0.8.5" serde = ">=1.0.140" diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 68d95c87468..14d03b5926d 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -1630,7 +1630,7 @@ pub fn withdraw_obligation_collateral_and_redeem_reserve_collateral( AccountMeta::new(destination_liquidity_pubkey, false), AccountMeta::new(reserve_collateral_mint_pubkey, false), AccountMeta::new(reserve_liquidity_supply_pubkey, false), - AccountMeta::new_readonly(obligation_owner_pubkey, true), + AccountMeta::new(obligation_owner_pubkey, true), AccountMeta::new_readonly(user_transfer_authority_pubkey, true), AccountMeta::new_readonly(spl_token::id(), false), ]; @@ -1672,7 +1672,7 @@ pub fn withdraw_obligation_collateral( let mut accounts = vec![ AccountMeta::new(source_collateral_pubkey, false), AccountMeta::new(destination_collateral_pubkey, false), - AccountMeta::new_readonly(withdraw_reserve_pubkey, false), + AccountMeta::new(withdraw_reserve_pubkey, false), AccountMeta::new(obligation_pubkey, false), AccountMeta::new_readonly(lending_market_pubkey, false), AccountMeta::new_readonly(lending_market_authority_pubkey, false), @@ -1790,7 +1790,7 @@ pub fn liquidate_obligation( AccountMeta::new(destination_collateral_pubkey, false), AccountMeta::new(repay_reserve_pubkey, false), AccountMeta::new(repay_reserve_liquidity_supply_pubkey, false), - AccountMeta::new_readonly(withdraw_reserve_pubkey, false), + AccountMeta::new(withdraw_reserve_pubkey, false), AccountMeta::new(withdraw_reserve_collateral_supply_pubkey, false), AccountMeta::new(obligation_pubkey, false), AccountMeta::new_readonly(lending_market_pubkey, false), diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 1afc68e4b6f..0d5a05cb3a3 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -2,9 +2,13 @@ use super::pack_decimal; use crate::{ error::LendingError, math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, - state::unpack_decimal, + state::{unpack_decimal, PositionKind}, }; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use core::{ + convert::TryInto, + ops::{Deref, DerefMut}, +}; use solana_program::msg; use solana_program::program_pack::{Pack, Sealed}; use solana_program::{ @@ -12,7 +16,7 @@ use solana_program::{ program_error::ProgramError, pubkey::{Pubkey, PUBKEY_BYTES}, }; -use std::convert::{TryFrom, TryInto}; +use std::convert::TryFrom; /// Determines the size of [PoolRewardManager] pub const MAX_REWARDS: usize = 50; @@ -64,7 +68,9 @@ pub enum PoolRewardSlot { last_pool_reward_id: PoolRewardId, /// An optimization to avoid writing data that has not changed. /// When vacating a slot we set this to true. - has_been_vacated_in_this_tx: bool, + /// That way the packing logic knows whether it's fine to skip the + /// packing or not. + has_been_just_vacated: bool, }, /// Reward has not been closed yet. /// @@ -118,20 +124,23 @@ pub struct PoolReward { pub cumulative_rewards_per_share: Decimal, } +/// Wraps over user reward managers and allows mutable access to them while +/// other obligation fields are borrowed. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UserRewardManagers(pub Vec); + /// Tracks user's LM rewards for a specific pool (reserve.) -#[derive(Debug, PartialEq, Eq, Default, Clone)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct UserRewardManager { - /// User cannot both borrow and deposit in the same reserve. - /// This manager is unique for this reserve within an obligation. - /// - /// We know whether to use [crate::state::Reserve]'s - /// `deposits_pool_reward_manager` or `borrows_pool_reward_manager` based on - /// this field. - /// - /// One optimization we could make is to link the [UserRewardManager] via - /// index which would save 32 bytes per [UserRewardManager]. - /// However, that does make the program logic more error prone. + /// Links this manager to a reserve. pub reserve: Pubkey, + /// Although a user cannot both borrow and deposit in the same reserve, they + /// can deposit, withdraw and then borrow the same reserve. + /// Meanwhile they could've accumulated some rewards that'd be lost. + /// + /// Also, have an explicit distinguish between borrow and deposit doesn't + /// suffer from a footgun of misattributing rewards. + pub position_kind: PositionKind, /// For deposits, this is the amount of collateral token user has in /// their obligation deposit. /// @@ -196,7 +205,7 @@ impl PoolRewardManager { let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); - if start_time_secs <= end_time_secs { + if start_time_secs >= end_time_secs { msg!("Pool reward must end after it starts"); return Err(LendingError::MathOverflow.into()); } @@ -302,7 +311,7 @@ impl PoolRewardManager { self.pool_rewards[pool_reward_index] = PoolRewardSlot::Vacant { last_pool_reward_id: pool_reward.id, - has_been_vacated_in_this_tx: true, + has_been_just_vacated: true, }; Ok(vault) @@ -360,7 +369,7 @@ impl PoolRewardManager { /// When creating a new [UserRewardManager] we need to know whether we should /// populate it with rewards or not. -pub enum CreatingNewUserRewardManager { +enum CreatingNewUserRewardManager { /// If we are creating a [UserRewardManager] then we want to populate it. Yes, /// If we are updating an existing [UserRewardManager] then we don't want @@ -368,11 +377,96 @@ pub enum CreatingNewUserRewardManager { No, } +impl UserRewardManagers { + /// Returns [UserRewardManager] for the given reserve if any + pub fn find_mut( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + ) -> Option<&mut UserRewardManager> { + self.0.iter_mut().find(|user_reward_manager| { + user_reward_manager.reserve == reserve + && user_reward_manager.position_kind == position_kind + }) + } + + /// Updates the [UserRewardManager] for the given reserve. + /// + /// The caller must make sure that the provided [PoolRewardManager] is valid + /// for the given reserve. + /// + /// If an associated [UserRewardManager] is not found, it will be created. + /// + /// # Important + /// + /// Only call this if you're sure that the obligation should be tracking + /// rewards for the given reserve. + pub fn set_share( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + pool_reward_manager: &mut PoolRewardManager, + new_share: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + let user_reward_manager = if let Some(user_reward_manager) = + self.find_mut(reserve, position_kind) + { + user_reward_manager.update(pool_reward_manager, clock)?; + user_reward_manager + } else { + let mut new_user_reward_manager = UserRewardManager::new(reserve, position_kind, clock); + new_user_reward_manager.populate(pool_reward_manager, clock)?; + self.0.push(new_user_reward_manager); + // SAFETY: we just pushed a new item to the vector so ok to unwrap + self.0.last_mut().unwrap() + }; + + user_reward_manager.set_share(pool_reward_manager, new_share); + + Ok(()) + } +} + impl UserRewardManager { + /// Creates a new empty [UserRewardManager] for the given reserve. + pub fn new(reserve: Pubkey, position_kind: PositionKind, clock: &Clock) -> Self { + Self { + reserve, + last_update_time_secs: clock.unix_timestamp as _, + position_kind, + share: 0, + rewards: Vec::new(), + } + } + + /// Sets new share value for this manager. + fn set_share(&mut self, pool_reward_manager: &mut PoolRewardManager, new_share: u64) { + msg!( + "For reserve {} there are {} total shares. \ + User's previous position was at {} and new is at {}", + self.reserve, + pool_reward_manager.total_shares, + self.share, + new_share + ); + + // This works even for migrations. + // User's old share is 0 although it shouldn't be bcs they have borrowed + // or deposited. + // We only now attribute the share to the user which is fine, it's as if + // they just now borrowed/deposited. + pool_reward_manager.total_shares = + pool_reward_manager.total_shares - self.share + new_share; + + self.share = new_share; + } + /// Claims all rewards that the user has earned. /// Returns how many tokens should be transferred to the user. /// /// # Note + /// /// Errors if there is no pool reward with this vault. pub fn claim_rewards( &mut self, @@ -380,9 +474,11 @@ impl UserRewardManager { vault: Pubkey, clock: &Clock, ) -> Result { + self.update(pool_reward_manager, clock)?; + let (pool_reward_index, pool_reward) = pool_reward_manager .pool_rewards - .iter() + .iter_mut() .enumerate() .find_map(move |(index, slot)| match slot { PoolRewardSlot::Occupied(pool_reward) if pool_reward.vault == vault => { @@ -392,10 +488,15 @@ impl UserRewardManager { }) .ok_or(LendingError::NoPoolRewardMatches)?; - let Some(user_reward) = self.rewards.iter_mut().find(|user_reward| { - user_reward.pool_reward_index == pool_reward_index - && user_reward.pool_reward_id == pool_reward.id - }) else { + let Some((user_reward_index, user_reward)) = + self.rewards + .iter_mut() + .enumerate() + .find(|(_, user_reward)| { + user_reward.pool_reward_index == pool_reward_index + && user_reward.pool_reward_id == pool_reward.id + }) + else { // User is not tracking this reward, nothing to claim. // Let's be graceful and make this a no-op. // Prevents failures when multiple parties crank rewards. @@ -404,24 +505,55 @@ impl UserRewardManager { let to_claim = user_reward.withdraw_earned_rewards()?; - if pool_reward.has_ended(clock) { - // If pool reward has ended then it will be removed from the user - // reward manager in the next update call. - // - // We could also complicate matters by doing updates in place when - // needed to save on CU if necessary. - self.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; + if pool_reward.has_ended(clock) && user_reward.earned_rewards.try_floor_u64()? == 0 { + // This reward won't be used anymore as it ended and the user + // claimed all there was to claim. + // We can clean up this user reward. + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist. + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; } Ok(to_claim) } + /// Should be updated before any interaction with rewards. + /// + /// Invoker must have checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + pub fn update( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update_(pool_reward_manager, clock, CreatingNewUserRewardManager::No) + } + + /// When user borrows/deposits for a new reserve this function copies all + /// reserve rewards from the pool manager to the user manager and starts + /// accruing rewards. + /// + /// Invoker must have checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + pub(crate) fn populate( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update_( + pool_reward_manager, + clock, + CreatingNewUserRewardManager::Yes, + ) + } + /// Should be updated before any interaction with rewards. /// /// # Assumption /// Invoker has checked that this [PoolRewardManager] matches the /// [UserRewardManager]. - pub fn update( + fn update_( &mut self, pool_reward_manager: &mut PoolRewardManager, clock: &Clock, @@ -454,11 +586,11 @@ impl UserRewardManager { .find(|(_, r)| r.pool_reward_index == pool_reward_index); let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; - let has_ended = self.last_update_time_secs > end_time_secs; + let has_ended_for_user = self.last_update_time_secs >= end_time_secs; match maybe_user_reward { Some((user_reward_index, user_reward)) - if has_ended && user_reward.earned_rewards.try_floor_u64()? == 0 => + if has_ended_for_user && user_reward.earned_rewards.try_floor_u64()? == 0 => { // Reward period ended and there's nothing to crank. // We can clean up this user reward. @@ -467,7 +599,7 @@ impl UserRewardManager { self.rewards.swap_remove(user_reward_index); pool_reward.num_user_reward_managers -= 1; } - _ if has_ended => { + _ if has_ended_for_user => { // reward period over & there are rewards yet to be cracked } Some((_, user_reward)) => { @@ -484,6 +616,9 @@ impl UserRewardManager { user_reward.cumulative_rewards_per_share = pool_reward.cumulative_rewards_per_share; } + None if pool_reward.start_time_secs > curr_unix_timestamp_secs => { + // reward period has not started yet + } None => { // user did not yet start accruing rewards @@ -532,7 +667,7 @@ impl PoolReward { /// Returns whether the reward has ended. pub fn has_ended(&self, clock: &Clock) -> bool { let end_time_secs = self.start_time_secs + self.duration_secs as u64; - clock.unix_timestamp as u64 > end_time_secs + clock.unix_timestamp as u64 >= end_time_secs } } @@ -556,7 +691,7 @@ impl Default for PoolRewardSlot { last_pool_reward_id: PoolRewardId(0), // this is used for initialization of the pool reward manager so // it makes sense as there are 0s in the account data already - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, } } } @@ -660,7 +795,7 @@ impl Pack for PoolRewardManager { PoolRewardSlot::Vacant { last_pool_reward_id: pool_reward_id, // nope, has been vacant since unpack - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, } } else { let raw_pool_reward_tail = @@ -705,7 +840,7 @@ impl PoolRewardSlot { let for_sure_has_not_changed = matches!( self, Self::Vacant { - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, .. } ); @@ -746,10 +881,11 @@ impl UserRewardManager { /// Length of data before [Self::rewards] tail. /// /// - [Self::reserve] + /// - [Self::position_kind] /// - [Self::share] /// - [Self::last_update_time_secs] /// - [Self::rewards] vector length as u8 - const HEAD_LEN: usize = PUBKEY_BYTES + 8 + 8 + 1; + const HEAD_LEN: usize = PUBKEY_BYTES + 1 + 8 + 8 + 1; /// How many bytes are needed to pack this [UserRewardManager]. pub(crate) fn size_in_bytes_when_packed(&self) -> usize { @@ -763,17 +899,25 @@ impl UserRewardManager { pub(crate) fn pack_into_slice(&self, output: &mut [u8]) { let raw_user_reward_manager = array_mut_ref![output, 0, UserRewardManager::HEAD_LEN]; - let (dst_reserve, dst_share, dst_last_update_time_secs, dst_user_rewards_len) = mut_array_refs![ + let ( + dst_reserve, + dst_position_kind, + dst_share, + dst_last_update_time_secs, + dst_user_rewards_len, + ) = mut_array_refs![ raw_user_reward_manager, PUBKEY_BYTES, + 1, // position_kind 8, // share 8, // last_update_time_secs 1 // length of rewards array that's next to come ]; + dst_reserve.copy_from_slice(self.reserve.as_ref()); + dst_position_kind.copy_from_slice(&(self.position_kind as u8).to_le_bytes()); dst_share.copy_from_slice(&self.share.to_le_bytes()); dst_last_update_time_secs.copy_from_slice(&self.last_update_time_secs.to_le_bytes()); - dst_reserve.copy_from_slice(self.reserve.as_ref()); dst_user_rewards_len.copy_from_slice( &({ debug_assert!(MAX_REWARDS >= self.rewards.len()); @@ -815,15 +959,23 @@ impl UserRewardManager { let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; #[allow(clippy::ptr_offset_with_cast)] - let (src_reserve, src_share, src_last_update_time_secs, src_user_rewards_len) = array_refs![ + let ( + src_reserve, + src_position_kind, + src_share, + src_last_update_time_secs, + src_user_rewards_len, + ) = array_refs![ raw_user_reward_manager_head, PUBKEY_BYTES, + 1, // position_kind 8, // share 8, // last_update_time_secs 1 // length of rewards array that's next to come ]; let reserve = Pubkey::new_from_array(*src_reserve); + let position_kind = u8::from_le_bytes(*src_position_kind).try_into()?; let user_rewards_len = u8::from_le_bytes(*src_user_rewards_len) as _; let share = u64::from_le_bytes(*src_share); let last_update_time_secs = u64::from_le_bytes(*src_last_update_time_secs); @@ -851,6 +1003,7 @@ impl UserRewardManager { Ok(Self { reserve, + position_kind, share, last_update_time_secs, rewards, @@ -858,15 +1011,44 @@ impl UserRewardManager { } } +impl Deref for UserRewardManagers { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for UserRewardManagers { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for UserRewardManager { + fn default() -> Self { + Self { + reserve: Pubkey::default(), + position_kind: PositionKind::Deposit, + share: 0, + last_update_time_secs: 0, + rewards: Vec::new(), + } + } +} + #[cfg(test)] mod tests { //! TODO: Rewrite these tests from their Suilend counterparts. //! TODO: Calculate test coverage and add tests for missing branches. use super::*; + use pretty_assertions::assert_eq; use proptest::prelude::*; use rand::Rng; + const SECONDS_IN_A_DAY: u64 = 86_400; + fn pool_reward_manager_strategy() -> impl Strategy { (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) } @@ -904,7 +1086,7 @@ mod tests { let mut m = PoolRewardManager::default(); m.pool_rewards[0] = PoolRewardSlot::Vacant { last_pool_reward_id: PoolRewardId(69), - has_been_vacated_in_this_tx: true, + has_been_just_vacated: true, }; let mut packed = vec![0u8; PoolRewardManager::LEN]; @@ -915,7 +1097,7 @@ mod tests { unpacked.pool_rewards[0], PoolRewardSlot::Vacant { last_pool_reward_id: PoolRewardId(69), - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, } ); } @@ -932,7 +1114,7 @@ mod tests { pool_reward, PoolRewardSlot::Vacant { last_pool_reward_id: PoolRewardId(0), - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, } ) }); @@ -949,34 +1131,459 @@ mod tests { assert!(required_realloc <= MAX_REALLOC); } + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_basic" test. #[test] fn it_tests_pool_reward_manager_basic() { - // TODO: rewrite Suilend "test_pool_reward_manager_basic" test + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup pool reward manager with one reward + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + assert_eq!( + pool_reward_manager.pool_rewards[0], + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: slnd_vault, + start_time_secs: 0, + duration_secs: 20 * SECONDS_IN_A_DAY as u32, + total_rewards: 100 * 1_000_000, + cumulative_rewards_per_share: Decimal::zero(), + num_user_reward_managers: 0, + })) + ); + } + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/100 shares + + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + } + + { + // 1/4 of the reward time passes + clock.unix_timestamp = 5 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 400/500 shares + + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 400); + } + + { + // 1/2 of the reward time passes + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 5 * 1_000_000); + + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 20 * 1_000_000); + } + + { + // set both user reward managers to 250/500 shares + user_reward_manager_1.set_share(&mut pool_reward_manager, 250); + user_reward_manager_2.set_share(&mut pool_reward_manager, 250); + } + + { + // the reward is finished + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } } + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_multiple_rewards" test. #[test] fn it_tests_pool_reward_manager_multiple_rewards() { - // TODO: rewrite Suilend "test_pool_reward_manager_multiple_rewards" + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault1 = Pubkey::new_unique(); // where rewards are stored + let slnd_vault2 = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup a reward that starts now and lasts for 20 days + + pool_reward_manager + .add_pool_reward( + slnd_vault1, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + // and another reward that starts in 10 days and lasts for 10 days + + pool_reward_manager + .add_pool_reward( + slnd_vault2, + 10 * SECONDS_IN_A_DAY, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/100 shares + + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + } + + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + { + // setup user reward manager with 100/200 shares + + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 100); + } + + { + clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 87_500_000); + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 75 * 1_000_000); + + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 12_500_000); + + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } } + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_zero_share" test. #[test] fn it_tests_pool_reward_zero_share() { - // TODO: rewrite Suilend "test_pool_reward_manager_zero_share" + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + { + // setup pool reward manager with one reward + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 1); + + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + // 50 usdc is unallocated since there was zero share from 0-10 seconds + assert_eq!(claimed_slnd, 50 * 1_000_000); } + /// This tests replicates calculations from Suilend's + /// "test_pool_reward_manager_auto_farm" test. #[test] fn it_tests_pool_reward_manager_auto_farm() { - // TODO: rewrite Suilend "test_pool_reward_manager_auto_farm" + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 1); + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 1); + + { + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 75 * 1_000_000); + + user_reward_manager_2.set_share(&mut pool_reward_manager, 1); + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 25 * 1_000_000); + } } + /// This tests replicates Suilend's "test_add_too_many_pool_rewards" test. #[test] fn it_tests_add_too_many_pool_rewards() { - // TODO: rewrite Suilend "test_add_too_many_pool_rewards" + let clock = Clock::default(); + + let mut pool_reward_manager = PoolRewardManager::default(); + + for _ in 0..MAX_REWARDS { + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + } + + pool_reward_manager + .add_pool_reward( + Pubkey::new_unique(), + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect_err("It fails to add pool reward"); + } + + /// This tests replicates Suilend's + /// "test_pool_reward_manager_cancel_and_close" test. + #[test] + fn it_tests_pool_reward_manager_cancel_and_close() { + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward( + slnd_vault, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let (from_vault, unallocated_rewards) = pool_reward_manager + .cancel_pool_reward(0, &clock) + .expect("It cancels pool reward"); + assert_eq!(from_vault, slnd_vault); + assert_eq!(unallocated_rewards, 50 * 1_000_000); + } + + { + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 50 * 1_000_000); + } + + let from_vault = pool_reward_manager + .close_pool_reward(0) + .expect("It closes pool reward"); + assert_eq!(from_vault, slnd_vault); } + /// This tests replicates Suilend's + /// "test_pool_reward_manager_cancel_and_close_regression" test. #[test] fn it_tests_pool_reward_manager_cancel_and_close_regression() { - // TODO: rewrite Suilend "test_pool_reward_manager_cancel_and_close_regression" + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault1 = Pubkey::new_unique(); // where rewards are stored + let slnd_vault2 = Pubkey::new_unique(); // where rewards are stored + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward( + slnd_vault1, + 0, + 20 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + pool_reward_manager + .add_pool_reward( + slnd_vault2, + 20 * SECONDS_IN_A_DAY, + 30 * SECONDS_IN_A_DAY, + 100 * 1_000_000, + &clock, + ) + .expect("It adds pool reward"); + + let mut user_reward_manager_1 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_1 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_1.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + + let (from_vault, unallocated_rewards) = pool_reward_manager + .cancel_pool_reward(0, &clock) + .expect("It cancels pool reward"); + assert_eq!(from_vault, slnd_vault1); + assert_eq!(unallocated_rewards, 50 * 1_000_000); + + clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; + let claim_slnd = user_reward_manager_1 + .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) + .expect("It claims rewards"); + assert_eq!(claim_slnd, 50 * 1_000_000); + + let from_vault = pool_reward_manager + .close_pool_reward(0) + .expect("It closes pool reward"); + assert_eq!(from_vault, slnd_vault1); + } + + clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; + + let mut user_reward_manager_2 = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager_2 + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager_2.set_share(&mut pool_reward_manager, 100); + + { + clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; + + let claimed_slnd = user_reward_manager_2 + .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) + .expect("It claims rewards"); + assert_eq!(claimed_slnd, 50 * 1_000_000); + } } impl PoolRewardManager { @@ -990,7 +1597,7 @@ mod tests { if is_vacant { PoolRewardSlot::Vacant { last_pool_reward_id: Default::default(), - has_been_vacated_in_this_tx: false, + has_been_just_vacated: false, } } else { PoolRewardSlot::Occupied(Box::new(PoolReward { @@ -1013,6 +1620,7 @@ mod tests { let rewards_len = rng.gen_range(0..MAX_REWARDS); Self { reserve: Pubkey::new_unique(), + position_kind: rng.gen_range(0..=1u8).try_into().unwrap(), share: rng.gen(), last_update_time_secs: rng.gen(), rewards: std::iter::from_fn(|| { diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 94fcd8eccac..58d63715178 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -80,11 +80,11 @@ pub struct Obligation { /// # (Un)packing /// If there are no rewards to be collected then the obligation is packed /// as if there was no liquidity mining feature involved. - pub user_reward_managers: Vec, + pub user_reward_managers: UserRewardManagers, } /// These are the two foundational user interactions in a borrow-lending protocol. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum PositionKind { /// User is providing liquidity. Deposit = 0, @@ -115,26 +115,40 @@ impl Obligation { self.borrowed_value.try_div(self.deposited_value) } - /// Repay liquidity and remove it from borrows if zeroed out - pub fn repay(&mut self, settle_amount: Decimal, liquidity_index: usize) -> ProgramResult { + /// Repay liquidity and remove it from borrows if zeroed out. + /// + /// Returns current liability shares. + pub fn repay( + &mut self, + settle_amount: Decimal, + liquidity_index: usize, + ) -> Result { let liquidity = &mut self.borrows[liquidity_index]; if settle_amount == liquidity.borrowed_amount_wads { self.borrows.remove(liquidity_index); + Ok(0) } else { liquidity.repay(settle_amount)?; + liquidity.liability_shares() } - Ok(()) } - /// Withdraw collateral and remove it from deposits if zeroed out - pub fn withdraw(&mut self, withdraw_amount: u64, collateral_index: usize) -> ProgramResult { + /// Withdraw collateral and remove it from deposits if zeroed out. + /// + /// Returns the new deposited amount. + pub fn withdraw( + &mut self, + withdraw_amount: u64, + collateral_index: usize, + ) -> Result { let collateral = &mut self.deposits[collateral_index]; if withdraw_amount == collateral.deposited_amount { self.deposits.remove(collateral_index); + Ok(0) } else { collateral.withdraw(withdraw_amount)?; + Ok(collateral.deposited_amount) } - Ok(()) } /// calculate the maximum amount of collateral that can be borrowed @@ -335,16 +349,6 @@ impl Obligation { msg!("Reserve not found in obligation"); Err(LendingError::InvalidAccountInput.into()) } - - /// Returns [UserRewardManager] for the given reserve - pub fn find_user_reward_manager_mut( - &mut self, - reserve: Pubkey, - ) -> Option<&mut UserRewardManager> { - self.user_reward_managers - .iter_mut() - .find(|user_reward_manager| user_reward_manager.reserve == reserve) - } } /// Initialize an obligation @@ -469,6 +473,13 @@ impl ObligationLiquidity { Ok(()) } + + /// Calculates shares for liquidity mining. + pub fn liability_shares(&self) -> Result { + self.borrowed_amount_wads + .try_div(self.cumulative_borrow_rate_wads)? + .try_floor_u64() + } } const OBLIGATION_COLLATERAL_LEN: usize = 88; // 32 + 8 + 16 + 32 @@ -485,13 +496,18 @@ impl Obligation { /// /// - [Self::user_reward_managers] vec length in u8 /// - [Self::user_reward_managers] vector - const MAX_LEN: usize = Self::MIN_LEN + 1 + MAX_OBLIGATION_RESERVES * UserRewardManager::MAX_LEN; + pub const MAX_LEN: usize = + Self::MIN_LEN + 1 + MAX_OBLIGATION_RESERVES * UserRewardManager::MAX_LEN; /// How many bytes are needed to pack this [UserRewardManager]. pub fn size_in_bytes_when_packed(&self) -> usize { + if self.user_reward_managers.is_empty() { + return OBLIGATION_LEN_V1; + } + let mut size = OBLIGATION_LEN_V1 + 1; - for reward_manager in &self.user_reward_managers { + for reward_manager in self.user_reward_managers.iter() { size += reward_manager.size_in_bytes_when_packed(); } @@ -807,7 +823,7 @@ impl Obligation { super_unhealthy_borrow_value: unpack_decimal(super_unhealthy_borrow_value), borrowing_isolated_asset: unpack_bool(borrowing_isolated_asset)?, closeable: unpack_bool(closeable)?, - user_reward_managers, + user_reward_managers: UserRewardManagers(user_reward_managers), }) } } @@ -871,10 +887,11 @@ mod test { closeable: rng.gen(), user_reward_managers: { let user_reward_managers_len = rng.gen_range(0..=MAX_OBLIGATION_RESERVES); - - std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) - .take(user_reward_managers_len) - .collect() + UserRewardManagers( + std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) + .take(user_reward_managers_len) + .collect(), + ) }, } } @@ -958,8 +975,9 @@ mod test { fn repay_partial_amounts()(amount in 1..=u64::MAX)( repay_amount in Just(WAD as u128 * amount as u128), borrowed_amount in (WAD as u128 * amount as u128 + 1)..=MAX_BORROWED, - ) -> (u128, u128) { - (repay_amount, borrowed_amount) + cumulative_borrow_rate in (WAD as u128)..=(WAD as u128 * MAX_COMPOUNDED_INTEREST as u128), + ) -> (u128, u128, u128) { + (repay_amount, borrowed_amount, cumulative_borrow_rate) } } @@ -975,19 +993,22 @@ mod test { proptest! { #[test] fn repay_partial( - (repay_amount, borrowed_amount) in repay_partial_amounts(), + (repay_amount, borrowed_amount, cumulative_borrow_rate) in repay_partial_amounts(), ) { let borrowed_amount_wads = Decimal::from_scaled_val(borrowed_amount); let repay_amount_wads = Decimal::from_scaled_val(repay_amount); + let cumulative_borrow_rate_wads = Decimal::from_scaled_val(cumulative_borrow_rate); let mut obligation = Obligation { borrows: vec![ObligationLiquidity { borrowed_amount_wads, + cumulative_borrow_rate_wads, ..ObligationLiquidity::default() }], ..Obligation::default() }; - obligation.repay(repay_amount_wads, 0)?; + let liability_shares = obligation.repay(repay_amount_wads, 0)?; + assert_ne!(liability_shares, 0); assert!(obligation.borrows[0].borrowed_amount_wads < borrowed_amount_wads); assert!(obligation.borrows[0].borrowed_amount_wads > Decimal::zero()); } @@ -998,9 +1019,11 @@ mod test { ) { let borrowed_amount_wads = Decimal::from_scaled_val(borrowed_amount); let repay_amount_wads = Decimal::from_scaled_val(repay_amount); + let cumulative_borrow_rate_wads = Decimal::one(); let mut obligation = Obligation { borrows: vec![ObligationLiquidity { borrowed_amount_wads, + cumulative_borrow_rate_wads, ..ObligationLiquidity::default() }], ..Obligation::default() diff --git a/token-lending/sdk/src/state/rate_limiter.rs b/token-lending/sdk/src/state/rate_limiter.rs index 55f86774018..142acb31cd1 100644 --- a/token-lending/sdk/src/state/rate_limiter.rs +++ b/token-lending/sdk/src/state/rate_limiter.rs @@ -205,7 +205,7 @@ impl Pack for RateLimiter { } #[cfg(test)] -pub fn rand_rate_limiter() -> RateLimiter { +pub(crate) fn rand_rate_limiter() -> RateLimiter { use rand::Rng; let mut rng = rand::thread_rng(); From ab5568c7ef78a774ca85f25bb3e2c2ea30ade15b Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Mon, 7 Apr 2025 20:13:42 +0200 Subject: [PATCH 10/19] [Liquidity Mining] Code reorg (9) (#208) * Moving logic into respective modules and hiding accessors * Making some accessors public for tests * Increasing budget limit * Adding a test to add pool reward * Adding borrow position kind --- .../liquidity_mining/add_pool_reward.rs | 8 +- .../program/tests/add_pool_reward.rs | 162 +++ .../tests/borrow_obligation_liquidity.rs | 5 +- .../tests/deposit_obligation_collateral.rs | 7 +- ...rve_liquidity_and_obligation_collateral.rs | 7 +- .../tests/helpers/solend_program_test.rs | 64 +- .../program/tests/isolated_tier_assets.rs | 6 +- token-lending/sdk/src/instruction.rs | 85 ++ .../sdk/src/state/liquidity_mining.rs | 1189 +---------------- .../liquidity_mining/pool_reward_manager.rs | 609 +++++++++ .../liquidity_mining/user_reward_manager.rs | 605 +++++++++ token-lending/sdk/src/state/obligation.rs | 11 +- 12 files changed, 1565 insertions(+), 1193 deletions(-) create mode 100644 token-lending/program/tests/add_pool_reward.rs create mode 100644 token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs create mode 100644 token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs index 0ea199c0f6a..c0cc3fc3cc5 100644 --- a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -55,7 +55,7 @@ struct AddPoolRewardAccounts<'a, 'info> { /// TBD: do we want to create another signer authority to be able to /// delegate reward management to a softer multisig? lending_market_owner_info: &'a AccountInfo<'info>, - /// ❓ we don't yet whether this is rent info + /// ❓ we don't yet know whether this is rent info rent_info: &'a AccountInfo<'info>, /// ✅ matches `lending_market_info` token_program_info: &'a AccountInfo<'info>, @@ -77,6 +77,8 @@ pub(crate) fn process( reward_token_amount: u64, accounts: &[AccountInfo], ) -> ProgramResult { + msg!("Adding {position_kind:?} pool reward from {start_time_secs}s to {end_time_secs}s",); + let clock = &Clock::get()?; let mut accounts = @@ -162,10 +164,6 @@ impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { msg!("Reward token vault provided must be owned by the token program"); return Err(LendingError::InvalidTokenOwner.into()); } - if !reward_token_vault_info.data.borrow().is_empty() { - msg!("Reward token vault provided must be empty"); - return Err(LendingError::InvalidAccountInput.into()); - } // check that accounts that should be writable are writable diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs new file mode 100644 index 00000000000..d2408ccdbc5 --- /dev/null +++ b/token-lending/program/tests/add_pool_reward.rs @@ -0,0 +1,162 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use helpers::solend_program_test::{setup_world, LiqMiningReward}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::{Keypair, Signer}; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, +}; +use solend_sdk::state::{PoolReward, PoolRewardSlot, UserReward}; + +#[tokio::test] +async fn test_success_for_deposit() { + test_success(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_success_for_borrow() { + test_success(PositionKind::Borrow).await; +} + +async fn test_success(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let current_time = test.get_clock().await.unix_timestamp as u64; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }, + position_kind, + current_time, + current_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("This should succeed"); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time as _, + pool_rewards: { + let mut og = usdc_reserve_post + .account + .deposits_pool_reward_manager + .pool_rewards + .clone(); + + og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: current_time, + duration_secs, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + let expected_share = match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + + let deposit_amount = 1_000_000; + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + deposit_amount, + ) + .await + .expect("This should succeed"); + + deposit_amount + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &wsol_reserve, + &obligation, + &user, + 420_000_000, + ) + .await + .expect("This should succeed"); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &usdc_reserve, + &obligation, + &user, + None, + 690, + ) + .await + .unwrap(); + + 690 + } + }; + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_post.account.user_reward_managers.last().unwrap(), + &UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind, + share: expected_share, + last_update_time_secs: current_time as _, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards: Decimal::zero(), + cumulative_rewards_per_share: Decimal::zero(), + }], + } + ); +} diff --git a/token-lending/program/tests/borrow_obligation_liquidity.rs b/token-lending/program/tests/borrow_obligation_liquidity.rs index 02cbba53a6b..9774d0d8304 100644 --- a/token-lending/program/tests/borrow_obligation_liquidity.rs +++ b/token-lending/program/tests/borrow_obligation_liquidity.rs @@ -286,7 +286,7 @@ async fn test_success() { let last_update_time_secs = obligation.account.user_reward_managers[0].last_update_time_secs; - UserRewardManagers(vec![ + vec![ UserRewardManager { reserve: usdc_reserve.pubkey, position_kind: PositionKind::Deposit, @@ -301,7 +301,8 @@ async fn test_success() { last_update_time_secs, rewards: Vec::new(), }, - ]) + ] + .into() }, ..obligation.account }, diff --git a/token-lending/program/tests/deposit_obligation_collateral.rs b/token-lending/program/tests/deposit_obligation_collateral.rs index 959630a5cd4..7e1d0cb5c6e 100644 --- a/token-lending/program/tests/deposit_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_obligation_collateral.rs @@ -17,7 +17,7 @@ use solana_sdk::transaction::TransactionError; use solend_program::math::Decimal; use solend_program::state::{ LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, - Reserve, UserRewardManager, UserRewardManagers, + Reserve, UserRewardManager, }; async fn setup() -> ( @@ -116,13 +116,14 @@ async fn test_success() { market_value: Decimal::zero(), // this field only gets updated on a refresh attributed_borrow_value: Decimal::zero() }], - user_reward_managers: UserRewardManagers(vec![UserRewardManager { + user_reward_managers: vec![UserRewardManager { reserve: usdc_reserve.pubkey, position_kind: PositionKind::Deposit, share: deposit_amount, last_update_time_secs, rewards: Vec::new(), - }]), + }] + .into(), ..obligation.account } ); diff --git a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs index 01fc216d29b..16eb199fa96 100644 --- a/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs +++ b/token-lending/program/tests/deposit_reserve_liquidity_and_obligation_collateral.rs @@ -16,7 +16,7 @@ use solana_sdk::signature::Keypair; use solend_program::math::Decimal; use solend_program::state::{ LastUpdate, LendingMarket, Obligation, ObligationCollateral, PoolRewardManager, PositionKind, - Reserve, ReserveCollateral, ReserveLiquidity, UserRewardManager, UserRewardManagers, + Reserve, ReserveCollateral, ReserveLiquidity, UserRewardManager, }; async fn setup() -> ( @@ -145,13 +145,14 @@ async fn test_success() { attributed_borrow_value: Decimal::zero() }] .to_vec(), - user_reward_managers: UserRewardManagers(vec![UserRewardManager { + user_reward_managers: vec![UserRewardManager { reserve: usdc_reserve.pubkey, position_kind: PositionKind::Deposit, share: deposit_amount, last_update_time_secs: last_update_time_secs, rewards: Vec::new(), - }]), + }] + .into(), ..obligation.account } ); diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 323dcdcc550..95728089c6b 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -65,7 +65,7 @@ mod cu_budgets { pub(super) const BORROW_OBLIGATION_LIQUIDITY: u32 = 180_005; pub(super) const REPAY_OBLIGATION_LIQUIDITY: u32 = 70_006; pub(super) const REDEEM_FEES: u32 = 80_007; - pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 230_008; + pub(super) const LIQUIDATE_OBLIGATION_AND_REDEEM_RESERVE_COLLATERAL: u32 = 250_008; pub(super) const WITHDRAW_OBLIGATION_COLLATERAL_AND_REDEEM_RESERVE_COLLATERAL: u32 = 200_009; pub(super) const WITHDRAW_OBLIGATION_COLLATERAL: u32 = 130_010; pub(super) const INIT_RESERVE: u32 = 90_011; @@ -74,6 +74,7 @@ mod cu_budgets { pub(super) const UPDATE_RESERVE_CONFIG: u32 = 30_014; pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; pub(super) const REDEEM: u32 = 90_016; + pub(super) const ADD_POOL_REWARD: u32 = 80_017; } /// This is at most how many bytes can an obligation grow. @@ -106,6 +107,11 @@ pub struct Info { pub account: T, } +pub struct LiqMiningReward { + pub mint: Pubkey, + pub vault: Keypair, +} + impl SolendProgramTest { pub async fn start_with_test(mut test: ProgramTest) -> Self { test.prefer_bpf(false); @@ -366,6 +372,12 @@ impl SolendProgramTest { keypair.pubkey() } + pub async fn create_mint_as_test_authority(&mut self) -> Pubkey { + let mint = self.create_mint(&self.authority.pubkey()).await; + self.mints.insert(mint, None); + mint + } + pub async fn create_mint(&mut self, mint_authority: &Pubkey) -> Pubkey { let keypair = Keypair::new(); let rent = self.rent.minimum_balance(Mint::LEN); @@ -903,6 +915,56 @@ impl Info { .await } + pub async fn add_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + user: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_amount: u64, + ) -> Result<(), BanksClientError> { + let token_account = user.create_token_account(&reward.mint, test).await; + test.mint_to(&reward.mint, &token_account.pubkey, reward_amount) + .await; + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::ADD_POOL_REWARD), + system_instruction::create_account( + &test.context.payer.pubkey(), + &reward.vault.pubkey(), + test.rent.minimum_balance(Token::LEN), + spl_token::state::Account::LEN as u64, + &spl_token::id(), + ), + add_pool_reward( + solend_program::id(), + position_kind, + start_time_secs, + end_time_secs, + reward_amount, + reserve.pubkey, + reward.mint, + token_account.pubkey, + find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ) + .0, + reward.vault.pubkey(), + self.pubkey, + user.keypair.pubkey(), + ), + ]; + + test.process_transaction(&instructions, Some(&[&user.keypair, &reward.vault])) + .await + } + pub async fn donate_to_reserve( &self, test: &mut SolendProgramTest, diff --git a/token-lending/program/tests/isolated_tier_assets.rs b/token-lending/program/tests/isolated_tier_assets.rs index 4fd5c411eef..62a2a5cfaac 100644 --- a/token-lending/program/tests/isolated_tier_assets.rs +++ b/token-lending/program/tests/isolated_tier_assets.rs @@ -19,7 +19,6 @@ use solend_program::state::LastUpdate; use solend_program::state::ReserveType; use solend_program::state::{ Obligation, ObligationLiquidity, PositionKind, ReserveConfig, UserRewardManager, - UserRewardManagers, }; use solend_sdk::state::ReserveFees; @@ -140,7 +139,7 @@ async fn test_refresh_obligation() { unweighted_borrowed_value: Decimal::from(10u64), borrowed_value_upper_bound: Decimal::from(10u64), borrowing_isolated_asset: true, - user_reward_managers: UserRewardManagers(vec![ + user_reward_managers: vec![ UserRewardManager { reserve: usdc_reserve.pubkey, position_kind: PositionKind::Deposit, @@ -155,7 +154,8 @@ async fn test_refresh_obligation() { last_update_time_secs, rewards: Vec::new(), }, - ],), + ] + .into(), ..obligations[0].account.clone() } ); diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 14d03b5926d..4a69d5d82b2 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -2101,6 +2101,91 @@ pub fn upgrade_reserve_to_v2_1_0( } } +/// Creates a `AddPoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn add_pool_reward( + program_id: Pubkey, + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + token_amount: u64, + reserve_pubkey: Pubkey, + reward_mint_pubkey: Pubkey, + source_reward_token_account_pubkey: Pubkey, + reward_vault_authority_pubkey: Pubkey, + reward_vault_pubkey: Pubkey, + lending_market_pubkey: Pubkey, + lending_market_owner_pubkey: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve_pubkey, false), + AccountMeta::new(reward_mint_pubkey, false), + AccountMeta::new(source_reward_token_account_pubkey, false), + AccountMeta::new(reward_vault_authority_pubkey, false), + AccountMeta::new(reward_vault_pubkey, false), + AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new_readonly(lending_market_owner_pubkey, true), + AccountMeta::new_readonly(sysvar::rent::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::AddPoolReward { + position_kind, + start_time_secs, + end_time_secs, + token_amount, + } + .pack(), + } +} + +/// Derives the reward vault authority PDA address. +pub fn find_reward_vault_authority( + program_id: &Pubkey, + lending_market_key: &Pubkey, + reserve_key: &Pubkey, + reward_mint_key: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key), + program_id, + ) +} + +/// Creates a reward vault authority PDA address. +pub fn create_reward_vault_authority( + program_id: &Pubkey, + lending_market_key: &Pubkey, + reserve_key: &Pubkey, + reward_mint_key: &Pubkey, + bump: u8, +) -> Result { + Pubkey::create_program_address( + &[ + reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key) + .as_slice(), + &[&[bump]], + ] + .concat(), + program_id, + ) +} + +/// Returns seeds to derive the reward vault authority PDA address. +pub fn reward_vault_authority_seeds<'keys>( + lending_market_key: &'keys Pubkey, + reserve_key: &'keys Pubkey, + reward_mint_key: &'keys Pubkey, +) -> [&'keys [u8]; 4] { + [ + b"RewardVaultAuthority", + lending_market_key.as_ref(), + reserve_key.as_ref(), + reward_mint_key.as_ref(), + ] +} + #[cfg(test)] mod test { use super::*; diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 0d5a05cb3a3..4698b9a62cf 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -1,22 +1,10 @@ -use super::pack_decimal; -use crate::{ - error::LendingError, - math::{Decimal, TryAdd, TryDiv, TryMul, TrySub}, - state::{unpack_decimal, PositionKind}, -}; -use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; -use core::{ - convert::TryInto, - ops::{Deref, DerefMut}, -}; -use solana_program::msg; -use solana_program::program_pack::{Pack, Sealed}; -use solana_program::{ - clock::Clock, - program_error::ProgramError, - pubkey::{Pubkey, PUBKEY_BYTES}, -}; -use std::convert::TryFrom; +//! Liquidity mining feature built analogous to Suilend's implementation. + +pub mod pool_reward_manager; +pub mod user_reward_manager; + +pub use pool_reward_manager::*; +pub use user_reward_manager::*; /// Determines the size of [PoolRewardManager] pub const MAX_REWARDS: usize = 50; @@ -24,1113 +12,25 @@ pub const MAX_REWARDS: usize = 50; /// Cannot create a reward shorter than this. pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; -/// Each reserve has two managers: -/// - one for deposits -/// - one for borrows -#[derive(Clone, Debug, PartialEq)] -pub struct PoolRewardManager { - /// Is updated when we change user shares in the reserve. - pub total_shares: u64, - /// Monotonically increasing time taken from clock sysvar. - pub last_update_time_secs: u64, - /// New [PoolReward] are added to the first vacant slot. - pub pool_rewards: [PoolRewardSlot; MAX_REWARDS], -} - -/// Each pool reward gets an ID which is monotonically increasing with each -/// new reward added to the pool at the particular slot. -/// -/// This helps us distinguish between two distinct rewards in the same array -/// index across time. -/// -/// # Wrapping -/// There are two strategies to handle wrapping: -/// 1. Consider the associated slot locked forever -/// 2. Go back to 0. -/// -/// Given that one reward lasts at [MIN_REWARD_PERIOD_SECS] we've got at least -/// half a million years before we need to worry about wrapping in a single slot. -/// I'd call that someone else's problem. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct PoolRewardId(pub u32); - -/// # (Un)Packing -/// This is unpacked representation. -/// When packing we use the [PoolReward] `reward_mint` to determine whether the -/// reward is vacant or not to save space. -/// -/// If the pubkey is eq to default pubkey then slot is vacant. -#[derive(Clone, Debug, PartialEq)] -pub enum PoolRewardSlot { - /// New reward can be added to this slot. - Vacant { - /// Increment this ID when adding new [PoolReward]. - last_pool_reward_id: PoolRewardId, - /// An optimization to avoid writing data that has not changed. - /// When vacating a slot we set this to true. - /// That way the packing logic knows whether it's fine to skip the - /// packing or not. - has_been_just_vacated: bool, - }, - /// Reward has not been closed yet. - /// - /// We box the [PoolReward] to avoid stack overflow. - Occupied(Box), -} - -/// Tracks rewards in a specific mint over some period of time. -/// -/// # Reward cancellation -/// -/// In Suilend we also store the amount of rewards that have been made available -/// to users already. -/// We keep adding `(total_rewards * time_passed) / (total_time)` every -/// time someone interacts with the manager. -/// This value is used to transfer the unallocated rewards to the admin. -/// However, this can be calculated dynamically which avoids storing extra -/// [Decimal] on each [PoolReward]. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct PoolReward { - /// Unique ID for this slot that has never been used before, and will never - /// be used again. - pub id: PoolRewardId, - /// # (Un)Packing - /// When we pack the reward we set this to default pubkey for vacant slots. - pub vault: Pubkey, - /// Monotonically increasing time taken from clock sysvar. - pub start_time_secs: u64, - /// For how long (since start time) will this reward be releasing tokens. - /// - /// # Reward cancellation - /// - /// Is cut short if the reward is cancelled. - pub duration_secs: u32, - /// Total token amount to distribute. - /// The token account that holds the rewards holds at least this much in - /// the beginning. - pub total_rewards: u64, - /// How many users are still tracking this reward. - /// Once this reaches zero we can close this reward. - /// There's a permission-less ix with which user rewards can be distributed - /// that's used for cranking remaining rewards. - pub num_user_reward_managers: u64, - /// We keep adding `(unlocked_rewards) / (total_shares)` every time - /// someone interacts with the manager ([update_pool_reward_manager]) - /// where - /// `unlocked_rewards = (total_rewards * time_passed) / (total_time)` - /// - /// # (Un)Packing - /// We only store 16 most significant digits. - pub cumulative_rewards_per_share: Decimal, -} - -/// Wraps over user reward managers and allows mutable access to them while -/// other obligation fields are borrowed. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct UserRewardManagers(pub Vec); - -/// Tracks user's LM rewards for a specific pool (reserve.) -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct UserRewardManager { - /// Links this manager to a reserve. - pub reserve: Pubkey, - /// Although a user cannot both borrow and deposit in the same reserve, they - /// can deposit, withdraw and then borrow the same reserve. - /// Meanwhile they could've accumulated some rewards that'd be lost. - /// - /// Also, have an explicit distinguish between borrow and deposit doesn't - /// suffer from a footgun of misattributing rewards. - pub position_kind: PositionKind, - /// For deposits, this is the amount of collateral token user has in - /// their obligation deposit. - /// - /// For borrows, this is (borrow_amount / cumulative_borrow_rate) user - /// has in their obligation borrow. - pub share: u64, - /// Monotonically increasing time taken from clock sysvar. - pub last_update_time_secs: u64, - /// The indices on [Self::rewards] are _not_ correlated with - /// [PoolRewardManager::pool_rewards]. - /// Instead, this vector only tracks meaningful rewards for the user. - /// See [UserReward::pool_reward_index]. - /// - /// This is a diversion from the Suilend implementation. - pub rewards: Vec, -} - -/// Track user rewards for a specific [PoolReward]. -#[derive(Debug, PartialEq, Eq, Default, Clone)] -pub struct UserReward { - /// Which [PoolReward] within the reserve's index does this [UserReward] - /// correspond to. - /// - /// # (Un)packing - /// There are ever only going to be at most [MAX_REWARDS]. - /// We therefore pack this value into a byte. - pub pool_reward_index: usize, - /// Each pool reward gets an ID which is monotonically increasing with each - /// new reward added to the pool. - pub pool_reward_id: PoolRewardId, - /// Before [UserReward.cumulative_rewards_per_share] is copied we find - /// time difference between current global rewards and last user update - /// rewards: - /// [PoolReward.cumulative_rewards_per_share] - [UserReward.cumulative_rewards_per_share] - /// - /// Then, we multiply that difference by [UserRewardManager.share] and - /// add the result to this counter. - pub earned_rewards: Decimal, - /// copied from [PoolReward.cumulative_rewards_per_share] at the time of the last update - pub cumulative_rewards_per_share: Decimal, -} - -impl PoolRewardManager { - /// Adds a new pool reward. - /// - /// Will first update itself. - /// - /// Start time will be set to now if it's in the past. - /// Must last at least [MIN_REWARD_PERIOD_SECS]. - /// The amount of tokens to distribute must be greater than zero. - /// - /// Will return an error if no slot can be found for the new reward. - pub fn add_pool_reward( - &mut self, - vault: Pubkey, - start_time_secs: u64, - end_time_secs: u64, - reward_token_amount: u64, - clock: &Clock, - ) -> Result<(), ProgramError> { - self.update(clock)?; - - let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); - - if start_time_secs >= end_time_secs { - msg!("Pool reward must end after it starts"); - return Err(LendingError::MathOverflow.into()); - } - - let duration_secs: u32 = { - // SAFETY: just checked that start time is strictly smaller - let d = end_time_secs - start_time_secs; - d.try_into().map_err(|_| { - msg!("Pool reward duration is too long"); - LendingError::MathOverflow - })? - }; - if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { - msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); - return Err(LendingError::PoolRewardPeriodTooShort.into()); - } - - if reward_token_amount == 0 { - msg!("Pool reward amount must be greater than zero"); - return Err(LendingError::InvalidAmount.into()); - } - - let eligible_slot = - self.pool_rewards - .iter_mut() - .enumerate() - .find_map(|(slot_index, slot)| match slot { - PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(id), - .. - } if *id < u32::MAX => Some((slot_index, PoolRewardId(*id + 1))), - _ => None, - }); - - let Some((slot_index, next_id)) = eligible_slot else { - msg!("No vacant slot found for the new pool reward"); - return Err(LendingError::NoVacantSlotForPoolReward.into()); - }; - - self.pool_rewards[slot_index] = PoolRewardSlot::Occupied(Box::new(PoolReward { - id: next_id, - vault, - start_time_secs, - duration_secs, - total_rewards: reward_token_amount, - num_user_reward_managers: 0, - cumulative_rewards_per_share: Decimal::zero(), - })); - - Ok(()) - } - - /// Sets the duration of the pool reward to now. - /// Returns the amount of unallocated rewards and the vault they are in. - pub fn cancel_pool_reward( - &mut self, - pool_reward_index: usize, - clock: &Clock, - ) -> Result<(Pubkey, u64), ProgramError> { - self.update(clock)?; - - let Some(PoolRewardSlot::Occupied(pool_reward)) = - self.pool_rewards.get_mut(pool_reward_index) - else { - msg!("Cannot cancel a non-existent pool reward"); - return Err(ProgramError::InvalidArgument); - }; - - if pool_reward.has_ended(clock) { - msg!("Cannot cancel a pool reward that has already ended"); - return Err(LendingError::InvalidAccountInput.into()); - } - - let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; - let unlocked_rewards = Decimal::from(pool_reward.total_rewards) - .try_mul(Decimal::from(since_start_secs))? - .try_div(Decimal::from(pool_reward.duration_secs as u64))? - .try_floor_u64()?; - let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; - - pool_reward.duration_secs = - u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); - - Ok((pool_reward.vault, remaining_rewards)) - } - - /// Closes a pool reward if it has been cancelled before. - /// Returns the vault the rewards are in. - pub fn close_pool_reward(&mut self, pool_reward_index: usize) -> Result { - let Some(PoolRewardSlot::Occupied(pool_reward)) = - self.pool_rewards.get_mut(pool_reward_index) - else { - msg!("Cannot close a non-existent pool reward"); - return Err(ProgramError::InvalidArgument); - }; - - if pool_reward.num_user_reward_managers > 0 { - msg!("Cannot close a pool reward with active user reward managers"); - return Err(LendingError::InvalidAccountInput.into()); - } - - let vault = pool_reward.vault; - - self.pool_rewards[pool_reward_index] = PoolRewardSlot::Vacant { - last_pool_reward_id: pool_reward.id, - has_been_just_vacated: true, - }; - - Ok(vault) - } - - /// Should be updated before any interaction with rewards. - fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> { - let curr_unix_timestamp_secs = clock.unix_timestamp as u64; - - if self.last_update_time_secs >= curr_unix_timestamp_secs { - return Ok(()); - } - - if self.total_shares == 0 { - self.last_update_time_secs = curr_unix_timestamp_secs; - return Ok(()); - } - - let last_update_time_secs = self.last_update_time_secs; - - // get rewards that started already and did not finish yet - let running_rewards = self - .pool_rewards - .iter_mut() - .filter_map(|r| match r { - PoolRewardSlot::Occupied(reward) => Some(reward), - _ => None, - }) - .filter(|r| curr_unix_timestamp_secs > r.start_time_secs) - .filter(|r| last_update_time_secs < (r.start_time_secs + r.duration_secs as u64)); - - for reward in running_rewards { - let end_time_secs = reward.start_time_secs + reward.duration_secs as u64; - let time_passed_secs = curr_unix_timestamp_secs - .min(end_time_secs) - .checked_sub(reward.start_time_secs.max(last_update_time_secs)) - .ok_or(LendingError::MathOverflow)?; - - // When adding a reward we assert that a reward lasts for at least [MIN_REWARD_PERIOD_SECS]. - // Hence this won't error on overflow nor on division by zero. - let unlocked_rewards = Decimal::from(reward.total_rewards) - .try_mul(Decimal::from(time_passed_secs))? - .try_div(Decimal::from(end_time_secs - reward.start_time_secs))?; - - reward.cumulative_rewards_per_share = reward - .cumulative_rewards_per_share - .try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?; - } - - self.last_update_time_secs = curr_unix_timestamp_secs; - - Ok(()) - } -} - -/// When creating a new [UserRewardManager] we need to know whether we should -/// populate it with rewards or not. -enum CreatingNewUserRewardManager { - /// If we are creating a [UserRewardManager] then we want to populate it. - Yes, - /// If we are updating an existing [UserRewardManager] then we don't want - /// to populate it. - No, -} - -impl UserRewardManagers { - /// Returns [UserRewardManager] for the given reserve if any - pub fn find_mut( - &mut self, - reserve: Pubkey, - position_kind: PositionKind, - ) -> Option<&mut UserRewardManager> { - self.0.iter_mut().find(|user_reward_manager| { - user_reward_manager.reserve == reserve - && user_reward_manager.position_kind == position_kind - }) - } - - /// Updates the [UserRewardManager] for the given reserve. - /// - /// The caller must make sure that the provided [PoolRewardManager] is valid - /// for the given reserve. - /// - /// If an associated [UserRewardManager] is not found, it will be created. - /// - /// # Important - /// - /// Only call this if you're sure that the obligation should be tracking - /// rewards for the given reserve. - pub fn set_share( - &mut self, - reserve: Pubkey, - position_kind: PositionKind, - pool_reward_manager: &mut PoolRewardManager, - new_share: u64, - clock: &Clock, - ) -> Result<(), ProgramError> { - let user_reward_manager = if let Some(user_reward_manager) = - self.find_mut(reserve, position_kind) - { - user_reward_manager.update(pool_reward_manager, clock)?; - user_reward_manager - } else { - let mut new_user_reward_manager = UserRewardManager::new(reserve, position_kind, clock); - new_user_reward_manager.populate(pool_reward_manager, clock)?; - self.0.push(new_user_reward_manager); - // SAFETY: we just pushed a new item to the vector so ok to unwrap - self.0.last_mut().unwrap() - }; - - user_reward_manager.set_share(pool_reward_manager, new_share); - - Ok(()) - } -} - -impl UserRewardManager { - /// Creates a new empty [UserRewardManager] for the given reserve. - pub fn new(reserve: Pubkey, position_kind: PositionKind, clock: &Clock) -> Self { - Self { - reserve, - last_update_time_secs: clock.unix_timestamp as _, - position_kind, - share: 0, - rewards: Vec::new(), - } - } - - /// Sets new share value for this manager. - fn set_share(&mut self, pool_reward_manager: &mut PoolRewardManager, new_share: u64) { - msg!( - "For reserve {} there are {} total shares. \ - User's previous position was at {} and new is at {}", - self.reserve, - pool_reward_manager.total_shares, - self.share, - new_share - ); - - // This works even for migrations. - // User's old share is 0 although it shouldn't be bcs they have borrowed - // or deposited. - // We only now attribute the share to the user which is fine, it's as if - // they just now borrowed/deposited. - pool_reward_manager.total_shares = - pool_reward_manager.total_shares - self.share + new_share; - - self.share = new_share; - } - - /// Claims all rewards that the user has earned. - /// Returns how many tokens should be transferred to the user. - /// - /// # Note - /// - /// Errors if there is no pool reward with this vault. - pub fn claim_rewards( - &mut self, - pool_reward_manager: &mut PoolRewardManager, - vault: Pubkey, - clock: &Clock, - ) -> Result { - self.update(pool_reward_manager, clock)?; - - let (pool_reward_index, pool_reward) = pool_reward_manager - .pool_rewards - .iter_mut() - .enumerate() - .find_map(move |(index, slot)| match slot { - PoolRewardSlot::Occupied(pool_reward) if pool_reward.vault == vault => { - Some((index, pool_reward)) - } - _ => None, - }) - .ok_or(LendingError::NoPoolRewardMatches)?; - - let Some((user_reward_index, user_reward)) = - self.rewards - .iter_mut() - .enumerate() - .find(|(_, user_reward)| { - user_reward.pool_reward_index == pool_reward_index - && user_reward.pool_reward_id == pool_reward.id - }) - else { - // User is not tracking this reward, nothing to claim. - // Let's be graceful and make this a no-op. - // Prevents failures when multiple parties crank rewards. - return Ok(0); - }; - - let to_claim = user_reward.withdraw_earned_rewards()?; - - if pool_reward.has_ended(clock) && user_reward.earned_rewards.try_floor_u64()? == 0 { - // This reward won't be used anymore as it ended and the user - // claimed all there was to claim. - // We can clean up this user reward. - // We're fine with swap remove bcs `user_reward_index` is meaningless. - // SAFETY: We got the index from enumeration, so must exist. - self.rewards.swap_remove(user_reward_index); - pool_reward.num_user_reward_managers -= 1; - } - - Ok(to_claim) - } - - /// Should be updated before any interaction with rewards. - /// - /// Invoker must have checked that this [PoolRewardManager] matches the - /// [UserRewardManager]. - pub fn update( - &mut self, - pool_reward_manager: &mut PoolRewardManager, - clock: &Clock, - ) -> Result<(), ProgramError> { - self.update_(pool_reward_manager, clock, CreatingNewUserRewardManager::No) - } - - /// When user borrows/deposits for a new reserve this function copies all - /// reserve rewards from the pool manager to the user manager and starts - /// accruing rewards. - /// - /// Invoker must have checked that this [PoolRewardManager] matches the - /// [UserRewardManager]. - pub(crate) fn populate( - &mut self, - pool_reward_manager: &mut PoolRewardManager, - clock: &Clock, - ) -> Result<(), ProgramError> { - self.update_( - pool_reward_manager, - clock, - CreatingNewUserRewardManager::Yes, - ) - } - - /// Should be updated before any interaction with rewards. - /// - /// # Assumption - /// Invoker has checked that this [PoolRewardManager] matches the - /// [UserRewardManager]. - fn update_( - &mut self, - pool_reward_manager: &mut PoolRewardManager, - clock: &Clock, - creating_new_reward_manager: CreatingNewUserRewardManager, - ) -> Result<(), ProgramError> { - pool_reward_manager.update(clock)?; - - let curr_unix_timestamp_secs = clock.unix_timestamp as u64; - - if matches!( - creating_new_reward_manager, - CreatingNewUserRewardManager::No - ) && curr_unix_timestamp_secs == self.last_update_time_secs - { - return Ok(()); - } - - for (pool_reward_index, pool_reward) in - pool_reward_manager.pool_rewards.iter_mut().enumerate() - { - let PoolRewardSlot::Occupied(pool_reward) = pool_reward else { - // no reward to track - continue; - }; - - let maybe_user_reward = self - .rewards - .iter_mut() - .enumerate() - .find(|(_, r)| r.pool_reward_index == pool_reward_index); - - let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; - let has_ended_for_user = self.last_update_time_secs >= end_time_secs; - - match maybe_user_reward { - Some((user_reward_index, user_reward)) - if has_ended_for_user && user_reward.earned_rewards.try_floor_u64()? == 0 => - { - // Reward period ended and there's nothing to crank. - // We can clean up this user reward. - // We're fine with swap remove bcs `user_reward_index` is meaningless. - // SAFETY: We got the index from enumeration, so must exist. - self.rewards.swap_remove(user_reward_index); - pool_reward.num_user_reward_managers -= 1; - } - _ if has_ended_for_user => { - // reward period over & there are rewards yet to be cracked - } - Some((_, user_reward)) => { - // user is already accruing rewards, add the difference - - let new_reward_amount = pool_reward - .cumulative_rewards_per_share - .try_sub(user_reward.cumulative_rewards_per_share)? - .try_mul(Decimal::from(self.share))?; - - user_reward.earned_rewards = - user_reward.earned_rewards.try_add(new_reward_amount)?; - - user_reward.cumulative_rewards_per_share = - pool_reward.cumulative_rewards_per_share; - } - None if pool_reward.start_time_secs > curr_unix_timestamp_secs => { - // reward period has not started yet - } - None => { - // user did not yet start accruing rewards - - let new_user_reward = UserReward { - pool_reward_index, - pool_reward_id: pool_reward.id, - cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, - earned_rewards: if self.last_update_time_secs <= pool_reward.start_time_secs - { - pool_reward - .cumulative_rewards_per_share - .try_mul(Decimal::from(self.share))? - } else { - debug_assert!(matches!( - creating_new_reward_manager, - CreatingNewUserRewardManager::Yes - )); - Decimal::zero() - }, - }; - - self.rewards.push(new_user_reward); - pool_reward.num_user_reward_managers += 1; - } - } - } - - self.last_update_time_secs = curr_unix_timestamp_secs; - - Ok(()) - } -} - -impl PoolReward { - const LEN: usize = Self::HEAD_LEN + Self::TAIL_LEN; - - const HEAD_LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES; - - /// - `start_time_secs`` - /// - `duration_secs`` - /// - `total_rewards`` - /// - `num_user_reward_managers`` - /// - `cumulative_rewards_per_share`` - const TAIL_LEN: usize = 8 + 4 + 8 + 8 + 16; - - /// Returns whether the reward has ended. - pub fn has_ended(&self, clock: &Clock) -> bool { - let end_time_secs = self.start_time_secs + self.duration_secs as u64; - clock.unix_timestamp as u64 >= end_time_secs - } -} - -impl PoolRewardId { - const LEN: usize = std::mem::size_of::(); -} - -impl Default for PoolRewardManager { - fn default() -> Self { - Self { - total_shares: 0, - last_update_time_secs: 0, - pool_rewards: std::array::from_fn(|_| PoolRewardSlot::default()), - } - } -} - -impl Default for PoolRewardSlot { - fn default() -> Self { - Self::Vacant { - last_pool_reward_id: PoolRewardId(0), - // this is used for initialization of the pool reward manager so - // it makes sense as there are 0s in the account data already - has_been_just_vacated: false, - } - } -} - -impl PoolRewardManager { - #[inline(never)] - pub(crate) fn unpack_to_box(input: &[u8]) -> Result, ProgramError> { - Ok(Box::new(PoolRewardManager::unpack_from_slice(input)?)) - } -} - -impl Sealed for PoolRewardManager {} - -impl Pack for PoolRewardManager { - /// total_shares + last_update_time_secs + pool_rewards. - const LEN: usize = 8 + 8 + MAX_REWARDS * PoolReward::LEN; - - fn pack_into_slice(&self, output: &mut [u8]) { - output[0..8].copy_from_slice(&self.total_shares.to_le_bytes()); - output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes()); - - let rewards_to_pack = self - .pool_rewards - .iter() - .enumerate() - .filter(|(_, s)| s.should_be_packed()); - - for (index, pool_reward_slot) in rewards_to_pack { - let offset = 16 + index * PoolReward::LEN; - - let raw_pool_reward_head = array_mut_ref![output, offset, PoolReward::HEAD_LEN]; - let (dst_id, dst_vault) = - mut_array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; - - match pool_reward_slot { - PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(id), - .. - } => { - dst_id.copy_from_slice(&id.to_le_bytes()); - dst_vault.copy_from_slice(Pubkey::default().as_ref()); - } - PoolRewardSlot::Occupied(pool_reward) => { - dst_id.copy_from_slice(&pool_reward.id.0.to_le_bytes()); - dst_vault.copy_from_slice(pool_reward.vault.as_ref()); - - let raw_pool_reward_tail = - array_mut_ref![output, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; - - let ( - dst_start_time_secs, - dst_duration_secs, - dst_total_rewards, - dst_num_user_reward_managers, - dst_cumulative_rewards_per_share_wads, - ) = mut_array_refs![ - raw_pool_reward_tail, - 8, // start_time_secs - 4, // duration_secs - 8, // total_rewards - 8, // num_user_reward_managers - 16 // cumulative_rewards_per_share - ]; - - *dst_start_time_secs = pool_reward.start_time_secs.to_le_bytes(); - *dst_duration_secs = pool_reward.duration_secs.to_le_bytes(); - *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); - *dst_num_user_reward_managers = - pool_reward.num_user_reward_managers.to_le_bytes(); - // TBD: do we want to ceil? - pack_decimal( - pool_reward.cumulative_rewards_per_share, - dst_cumulative_rewards_per_share_wads, - ); - } - }; - } - } - - #[inline(never)] - fn unpack_from_slice(input: &[u8]) -> Result { - let mut pool_reward_manager = PoolRewardManager { - total_shares: u64::from_le_bytes(*array_ref![input, 0, 8]), - last_update_time_secs: u64::from_le_bytes(*array_ref![input, 8, 8]), - ..Default::default() - }; - - for index in 0..MAX_REWARDS { - let offset = 8 + 8 + index * PoolReward::LEN; - let raw_pool_reward_head = array_ref![input, offset, PoolReward::HEAD_LEN]; - - #[allow(clippy::ptr_offset_with_cast)] - let (src_id, src_vault) = - array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; - - let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id)); - let vault = Pubkey::new_from_array(*src_vault); - - // SAFETY: ok to assign because we know the index is less than length - pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { - PoolRewardSlot::Vacant { - last_pool_reward_id: pool_reward_id, - // nope, has been vacant since unpack - has_been_just_vacated: false, - } - } else { - let raw_pool_reward_tail = - array_ref![input, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; - - let ( - src_start_time_secs, - src_duration_secs, - src_total_rewards, - src_num_user_reward_managers, - src_cumulative_rewards_per_share_wads, - ) = array_refs![ - raw_pool_reward_tail, - 8, // start_time_secs - 4, // duration_secs - 8, // total_rewards - 8, // num_user_reward_managers - 16 // cumulative_rewards_per_share - ]; - - PoolRewardSlot::Occupied(Box::new(PoolReward { - id: pool_reward_id, - vault, - start_time_secs: u64::from_le_bytes(*src_start_time_secs), - duration_secs: u32::from_le_bytes(*src_duration_secs), - total_rewards: u64::from_le_bytes(*src_total_rewards), - num_user_reward_managers: u64::from_le_bytes(*src_num_user_reward_managers), - cumulative_rewards_per_share: unpack_decimal( - src_cumulative_rewards_per_share_wads, - ), - })) - }; - } - - Ok(pool_reward_manager) - } -} - -impl PoolRewardSlot { - /// If we know for sure that data hasn't changed then we can just skip packing. - fn should_be_packed(&self) -> bool { - let for_sure_has_not_changed = matches!( - self, - Self::Vacant { - has_been_just_vacated: false, - .. - } - ); - - !for_sure_has_not_changed - } -} - -impl UserReward { - /// - [UserReward::pool_reward_index] truncated to a byte - /// - [PoolRewardId] - /// - packed [Decimal] - /// - packed [Decimal] - pub const LEN: usize = 1 + PoolRewardId::LEN + 16 + 16; - - /// Removes all earned rewards from [Self] and returns them. - /// - /// # Note - /// Decimals are truncated to u64, dust is kept. - fn withdraw_earned_rewards(&mut self) -> Result { - let reward_amount = self.earned_rewards.try_floor_u64()?; - - if reward_amount > 0 { - self.earned_rewards = self.earned_rewards.try_sub(reward_amount.into())?; - } - - Ok(reward_amount) - } -} - -impl UserRewardManager { - /// [Self] is dynamically sized based on how many [PoolReward]s are there - /// for the given [Self::reserve]. - /// - /// This is the maximum length a manager can have. - pub const MAX_LEN: usize = Self::HEAD_LEN + MAX_REWARDS * UserReward::LEN; - - /// Length of data before [Self::rewards] tail. - /// - /// - [Self::reserve] - /// - [Self::position_kind] - /// - [Self::share] - /// - [Self::last_update_time_secs] - /// - [Self::rewards] vector length as u8 - const HEAD_LEN: usize = PUBKEY_BYTES + 1 + 8 + 8 + 1; - - /// How many bytes are needed to pack this [UserRewardManager]. - pub(crate) fn size_in_bytes_when_packed(&self) -> usize { - Self::HEAD_LEN + self.rewards.len() * UserReward::LEN - } - - /// Because [Self] is dynamically sized we don't implement [Pack] that - /// contains a misleading const `LEN`. - /// - /// We return how many bytes were written. - pub(crate) fn pack_into_slice(&self, output: &mut [u8]) { - let raw_user_reward_manager = array_mut_ref![output, 0, UserRewardManager::HEAD_LEN]; - - let ( - dst_reserve, - dst_position_kind, - dst_share, - dst_last_update_time_secs, - dst_user_rewards_len, - ) = mut_array_refs![ - raw_user_reward_manager, - PUBKEY_BYTES, - 1, // position_kind - 8, // share - 8, // last_update_time_secs - 1 // length of rewards array that's next to come - ]; - - dst_reserve.copy_from_slice(self.reserve.as_ref()); - dst_position_kind.copy_from_slice(&(self.position_kind as u8).to_le_bytes()); - dst_share.copy_from_slice(&self.share.to_le_bytes()); - dst_last_update_time_secs.copy_from_slice(&self.last_update_time_secs.to_le_bytes()); - dst_user_rewards_len.copy_from_slice( - &({ - debug_assert!(MAX_REWARDS >= self.rewards.len()); - debug_assert!(u8::MAX >= MAX_REWARDS as _); - self.rewards.len() as u8 - }) - .to_le_bytes(), - ); - - for (index, user_reward) in self.rewards.iter().enumerate() { - let offset = Self::HEAD_LEN + index * UserReward::LEN; - let raw_user_reward = array_mut_ref![output, offset, UserReward::LEN]; - - let ( - dst_pool_reward_index, - dst_pool_reward_id, - dst_earned_rewards, - dst_cumulative_rewards_per_share, - ) = mut_array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; - - dst_pool_reward_id.copy_from_slice(&user_reward.pool_reward_id.0.to_le_bytes()); - pack_decimal(user_reward.earned_rewards, dst_earned_rewards); - pack_decimal( - user_reward.cumulative_rewards_per_share, - dst_cumulative_rewards_per_share, - ); - let pool_reward_index = { - assert!(user_reward.pool_reward_index < MAX_REWARDS); - assert!(MAX_REWARDS < u8::MAX as _); - // will always fit - user_reward.pool_reward_index as u8 - }; - dst_pool_reward_index.copy_from_slice(&pool_reward_index.to_le_bytes()); - } - } - - pub(crate) fn unpack_from_slice(input: &[u8]) -> Result { - #[allow(clippy::ptr_offset_with_cast)] - let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; - - #[allow(clippy::ptr_offset_with_cast)] - let ( - src_reserve, - src_position_kind, - src_share, - src_last_update_time_secs, - src_user_rewards_len, - ) = array_refs![ - raw_user_reward_manager_head, - PUBKEY_BYTES, - 1, // position_kind - 8, // share - 8, // last_update_time_secs - 1 // length of rewards array that's next to come - ]; - - let reserve = Pubkey::new_from_array(*src_reserve); - let position_kind = u8::from_le_bytes(*src_position_kind).try_into()?; - let user_rewards_len = u8::from_le_bytes(*src_user_rewards_len) as _; - let share = u64::from_le_bytes(*src_share); - let last_update_time_secs = u64::from_le_bytes(*src_last_update_time_secs); - - let mut rewards = Vec::with_capacity(user_rewards_len); - for index in 0..user_rewards_len { - let offset = Self::HEAD_LEN + index * UserReward::LEN; - let raw_user_reward = array_ref![input, offset, UserReward::LEN]; - - #[allow(clippy::ptr_offset_with_cast)] - let ( - src_pool_reward_index, - src_pool_reward_id, - src_earned_rewards, - src_cumulative_rewards_per_share, - ) = array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; - - rewards.push(UserReward { - pool_reward_index: u8::from_le_bytes(*src_pool_reward_index) as _, - pool_reward_id: PoolRewardId(u32::from_le_bytes(*src_pool_reward_id)), - earned_rewards: unpack_decimal(src_earned_rewards), - cumulative_rewards_per_share: unpack_decimal(src_cumulative_rewards_per_share), - }); - } - - Ok(Self { - reserve, - position_kind, - share, - last_update_time_secs, - rewards, - }) - } -} - -impl Deref for UserRewardManagers { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for UserRewardManagers { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Default for UserRewardManager { - fn default() -> Self { - Self { - reserve: Pubkey::default(), - position_kind: PositionKind::Deposit, - share: 0, - last_update_time_secs: 0, - rewards: Vec::new(), - } - } -} - #[cfg(test)] -mod tests { - //! TODO: Rewrite these tests from their Suilend counterparts. +mod suilend_tests { + //! These tests were taken from the Suilend's codebase and adapted to + //! the new codebase. + //! //! TODO: Calculate test coverage and add tests for missing branches. - use super::*; + use crate::{ + math::Decimal, + state::{ + PoolReward, PoolRewardId, PoolRewardManager, PoolRewardSlot, PositionKind, + UserRewardManager, MAX_REWARDS, + }, + }; use pretty_assertions::assert_eq; - use proptest::prelude::*; - use rand::Rng; + use solana_program::{clock::Clock, pubkey::Pubkey}; const SECONDS_IN_A_DAY: u64 = 86_400; - fn pool_reward_manager_strategy() -> impl Strategy { - (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) - } - - fn user_reward_manager_strategy() -> impl Strategy { - (0..100u32).prop_perturb(|_, mut rng| UserRewardManager::new_rand(&mut rng)) - } - - proptest! { - #[test] - fn it_packs_and_unpacks_pool_reward_manager(pool_reward_manager in pool_reward_manager_strategy()) { - let mut packed = vec![0u8; PoolRewardManager::LEN]; - Pack::pack_into_slice(&pool_reward_manager, &mut packed); - let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); - - prop_assert_eq!(pool_reward_manager.last_update_time_secs, unpacked.last_update_time_secs); - prop_assert_eq!(pool_reward_manager.total_shares, unpacked.total_shares); - - for (og, unpacked) in pool_reward_manager.pool_rewards.iter().zip(unpacked.pool_rewards.iter()) { - prop_assert_eq!(og, unpacked); - } - } - - #[test] - fn it_packs_and_unpacks_user_reward_manager(user_reward_manager in user_reward_manager_strategy()) { - let mut packed = vec![0u8; UserRewardManager::MAX_LEN]; - user_reward_manager.pack_into_slice(&mut packed); - let unpacked = UserRewardManager::unpack_from_slice(&packed).unwrap(); - prop_assert_eq!(user_reward_manager, unpacked); - } - } - - #[test] - fn it_packs_id_if_vacated_in_this_tx() { - let mut m = PoolRewardManager::default(); - m.pool_rewards[0] = PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(69), - has_been_just_vacated: true, - }; - - let mut packed = vec![0u8; PoolRewardManager::LEN]; - m.pack_into_slice(&mut packed); - let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); - - assert_eq!( - unpacked.pool_rewards[0], - PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(69), - has_been_just_vacated: false, - } - ); - } - - #[test] - fn it_unpacks_empty_pool_reward_manager_bytes_as_default() { - let packed = vec![0u8; PoolRewardManager::LEN]; - let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); - assert_eq!(unpacked, PoolRewardManager::default()); - - // sanity check that everything starts at 0 - let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| { - matches!( - pool_reward, - PoolRewardSlot::Vacant { - last_pool_reward_id: PoolRewardId(0), - has_been_just_vacated: false, - } - ) - }); - - assert!(all_rewards_are_empty); - } - - #[test] - fn it_fits_reserve_realloc_into_single_ix() { - const MAX_REALLOC: usize = solana_program::entrypoint::MAX_PERMITTED_DATA_INCREASE; - - let size_of_discriminant = 1; - let required_realloc = size_of_discriminant * PoolRewardManager::LEN; - assert!(required_realloc <= MAX_REALLOC); - } - /// This tests replicates calculations from Suilend's /// "test_pool_reward_manager_basic" test. #[test] @@ -1585,55 +485,4 @@ mod tests { assert_eq!(claimed_slnd, 50 * 1_000_000); } } - - impl PoolRewardManager { - pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { - Self { - total_shares: rng.gen(), - last_update_time_secs: rng.gen(), - pool_rewards: std::array::from_fn(|_| { - let is_vacant = rng.gen_bool(0.5); - - if is_vacant { - PoolRewardSlot::Vacant { - last_pool_reward_id: Default::default(), - has_been_just_vacated: false, - } - } else { - PoolRewardSlot::Occupied(Box::new(PoolReward { - id: PoolRewardId(rng.gen()), - vault: Pubkey::new_unique(), - start_time_secs: rng.gen(), - duration_secs: rng.gen(), - total_rewards: rng.gen(), - cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), - num_user_reward_managers: rng.gen(), - })) - } - }), - } - } - } - - impl UserRewardManager { - pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { - let rewards_len = rng.gen_range(0..MAX_REWARDS); - Self { - reserve: Pubkey::new_unique(), - position_kind: rng.gen_range(0..=1u8).try_into().unwrap(), - share: rng.gen(), - last_update_time_secs: rng.gen(), - rewards: std::iter::from_fn(|| { - Some(UserReward { - pool_reward_index: rng.gen_range(0..MAX_REWARDS), - pool_reward_id: PoolRewardId(rng.gen()), - earned_rewards: Decimal::from_scaled_val(rng.gen()), - cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), - }) - }) - .take(rewards_len) - .collect(), - } - } - } } diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs new file mode 100644 index 00000000000..ecfc83a9ccb --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -0,0 +1,609 @@ +//! [PoolRewardManager]s are stored in [crate::state::Reserve]s. +//! They can be either borrow or deposit but the logic is the same, the only +//! difference is how shares are calculated. +//! +//! For borrow managers the shares are "liability" and for deposit +//! managers the shares are "deposited collateral". + +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryDiv, TryMul}, + state::{pack_decimal, unpack_decimal, MAX_REWARDS, MIN_REWARD_PERIOD_SECS}, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use core::convert::{TryFrom, TryInto}; +use solana_program::{ + clock::Clock, + msg, + program_error::ProgramError, + program_pack::{Pack, Sealed}, + pubkey::{Pubkey, PUBKEY_BYTES}, +}; + +/// Each reserve has two managers: +/// - one for deposits +/// - one for borrows +#[derive(Clone, Debug, PartialEq)] +pub struct PoolRewardManager { + /// Is updated when we change user shares in the reserve. + pub total_shares: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// New [PoolReward] are added to the first vacant slot. + pub pool_rewards: [PoolRewardSlot; MAX_REWARDS], +} + +/// Each pool reward gets an ID which is monotonically increasing with each +/// new reward added to the pool at the particular slot. +/// +/// This helps us distinguish between two distinct rewards in the same array +/// index across time. +/// +/// # Wrapping +/// There are two strategies to handle wrapping: +/// 1. Consider the associated slot locked forever +/// 2. Go back to 0. +/// +/// Given that one reward lasts at [MIN_REWARD_PERIOD_SECS] we've got at least +/// half a million years before we need to worry about wrapping in a single slot. +/// I'd call that someone else's problem. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PoolRewardId(pub u32); + +/// # (Un)Packing +/// This is unpacked representation. +/// When packing we use the [PoolReward] `reward_mint` to determine whether the +/// reward is vacant or not to save space. +/// +/// If the pubkey is eq to default pubkey then slot is vacant. +#[derive(Clone, Debug, PartialEq)] +pub enum PoolRewardSlot { + /// New reward can be added to this slot. + Vacant { + /// Increment this ID when adding new [PoolReward]. + last_pool_reward_id: PoolRewardId, + /// An optimization to avoid writing data that has not changed. + /// When vacating a slot we set this to true. + /// That way the packing logic knows whether it's fine to skip the + /// packing or not. + has_been_just_vacated: bool, + }, + /// Reward has not been closed yet. + /// + /// We box the [PoolReward] to avoid stack overflow. + Occupied(Box), +} + +/// Tracks rewards in a specific mint over some period of time. +/// +/// # Reward cancellation +/// +/// In Suilend we also store the amount of rewards that have been made available +/// to users already. +/// We keep adding `(total_rewards * time_passed) / (total_time)` every +/// time someone interacts with the manager. +/// This value is used to transfer the unallocated rewards to the admin. +/// However, this can be calculated dynamically which avoids storing extra +/// [Decimal] on each [PoolReward]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PoolReward { + /// Unique ID for this slot that has never been used before, and will never + /// be used again. + pub id: PoolRewardId, + /// # (Un)Packing + /// When we pack the reward we set this to default pubkey for vacant slots. + pub vault: Pubkey, + /// Monotonically increasing time taken from clock sysvar. + pub start_time_secs: u64, + /// For how long (since start time) will this reward be releasing tokens. + /// + /// # Reward cancellation + /// + /// Is cut short if the reward is cancelled. + pub duration_secs: u32, + /// Total token amount to distribute. + /// The token account that holds the rewards holds at least this much in + /// the beginning. + pub total_rewards: u64, + /// How many users are still tracking this reward. + /// Once this reaches zero we can close this reward. + /// There's a permission-less ix with which user rewards can be distributed + /// that's used for cranking remaining rewards. + pub num_user_reward_managers: u64, + /// We keep adding `(unlocked_rewards) / (total_shares)` every time + /// someone interacts with the manager ([update_pool_reward_manager]) + /// where + /// `unlocked_rewards = (total_rewards * time_passed) / (total_time)` + /// + /// # (Un)Packing + /// We only store 16 most significant digits. + pub cumulative_rewards_per_share: Decimal, +} + +impl PoolRewardManager { + /// Adds a new pool reward. + /// + /// Will first update itself. + /// + /// Start time will be set to now if it's in the past. + /// Must last at least [MIN_REWARD_PERIOD_SECS]. + /// The amount of tokens to distribute must be greater than zero. + /// + /// Will return an error if no slot can be found for the new reward. + pub fn add_pool_reward( + &mut self, + vault: Pubkey, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update(clock)?; + + let start_time_secs = start_time_secs.max(clock.unix_timestamp as u64); + + if start_time_secs >= end_time_secs { + msg!("Pool reward must end after it starts"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + let duration_secs: u32 = { + // SAFETY: just checked that start time is strictly smaller + let d = end_time_secs - start_time_secs; + d.try_into().map_err(|_| { + msg!("Pool reward duration is too long"); + LendingError::MathOverflow + })? + }; + if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { + msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); + return Err(LendingError::PoolRewardPeriodTooShort.into()); + } + + if reward_token_amount == 0 { + msg!("Pool reward amount must be greater than zero"); + return Err(LendingError::InvalidAmount.into()); + } + + let eligible_slot = + self.pool_rewards + .iter_mut() + .enumerate() + .find_map(|(slot_index, slot)| match slot { + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(id), + .. + } if *id < u32::MAX => Some((slot_index, PoolRewardId(*id + 1))), + _ => None, + }); + + let Some((slot_index, next_id)) = eligible_slot else { + msg!("No vacant slot found for the new pool reward"); + return Err(LendingError::NoVacantSlotForPoolReward.into()); + }; + + self.pool_rewards[slot_index] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: next_id, + vault, + start_time_secs, + duration_secs, + total_rewards: reward_token_amount, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + Ok(()) + } + + /// Sets the duration of the pool reward to now. + /// Returns the amount of unallocated rewards and the vault they are in. + pub fn cancel_pool_reward( + &mut self, + pool_reward_index: usize, + clock: &Clock, + ) -> Result<(Pubkey, u64), ProgramError> { + self.update(clock)?; + + let Some(PoolRewardSlot::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot cancel a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.has_ended(clock) { + msg!("Cannot cancel a pool reward that has already ended"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; + let unlocked_rewards = Decimal::from(pool_reward.total_rewards) + .try_mul(Decimal::from(since_start_secs))? + .try_div(Decimal::from(pool_reward.duration_secs as u64))? + .try_floor_u64()?; + let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; + + pool_reward.duration_secs = + u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); + + Ok((pool_reward.vault, remaining_rewards)) + } + + /// Closes a pool reward if it has been cancelled before. + /// Returns the vault the rewards are in. + pub fn close_pool_reward(&mut self, pool_reward_index: usize) -> Result { + let Some(PoolRewardSlot::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot close a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.num_user_reward_managers > 0 { + msg!("Cannot close a pool reward with active user reward managers"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let vault = pool_reward.vault; + + self.pool_rewards[pool_reward_index] = PoolRewardSlot::Vacant { + last_pool_reward_id: pool_reward.id, + has_been_just_vacated: true, + }; + + Ok(vault) + } +} + +impl PoolRewardManager { + /// Should be updated before any interaction with rewards. + pub(crate) fn update(&mut self, clock: &Clock) -> Result<(), ProgramError> { + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + + if self.last_update_time_secs >= curr_unix_timestamp_secs { + return Ok(()); + } + + if self.total_shares == 0 { + self.last_update_time_secs = curr_unix_timestamp_secs; + return Ok(()); + } + + let last_update_time_secs = self.last_update_time_secs; + + // get rewards that started already and did not finish yet + let running_rewards = self + .pool_rewards + .iter_mut() + .filter_map(|r| match r { + PoolRewardSlot::Occupied(reward) => Some(reward), + _ => None, + }) + .filter(|r| curr_unix_timestamp_secs > r.start_time_secs) + .filter(|r| last_update_time_secs < (r.start_time_secs + r.duration_secs as u64)); + + for reward in running_rewards { + let end_time_secs = reward.start_time_secs + reward.duration_secs as u64; + let time_passed_secs = curr_unix_timestamp_secs + .min(end_time_secs) + .checked_sub(reward.start_time_secs.max(last_update_time_secs)) + .ok_or(LendingError::MathOverflow)?; + + // When adding a reward we assert that a reward lasts for at least [MIN_REWARD_PERIOD_SECS]. + // Hence this won't error on overflow nor on division by zero. + let unlocked_rewards = Decimal::from(reward.total_rewards) + .try_mul(Decimal::from(time_passed_secs))? + .try_div(Decimal::from(end_time_secs - reward.start_time_secs))?; + + reward.cumulative_rewards_per_share = reward + .cumulative_rewards_per_share + .try_add(unlocked_rewards.try_div(Decimal::from(self.total_shares))?)?; + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } +} + +impl PoolReward { + const LEN: usize = Self::HEAD_LEN + Self::TAIL_LEN; + + const HEAD_LEN: usize = PoolRewardId::LEN + PUBKEY_BYTES; + + /// - `start_time_secs`` + /// - `duration_secs`` + /// - `total_rewards`` + /// - `num_user_reward_managers`` + /// - `cumulative_rewards_per_share`` + const TAIL_LEN: usize = 8 + 4 + 8 + 8 + 16; + + /// Returns whether the reward has ended. + pub(crate) fn has_ended(&self, clock: &Clock) -> bool { + let end_time_secs = self.start_time_secs + self.duration_secs as u64; + clock.unix_timestamp as u64 >= end_time_secs + } +} + +impl PoolRewardId { + pub(crate) const LEN: usize = std::mem::size_of::(); +} + +impl Default for PoolRewardManager { + fn default() -> Self { + Self { + total_shares: 0, + last_update_time_secs: 0, + pool_rewards: std::array::from_fn(|_| PoolRewardSlot::default()), + } + } +} + +impl Default for PoolRewardSlot { + fn default() -> Self { + Self::Vacant { + last_pool_reward_id: PoolRewardId(0), + // this is used for initialization of the pool reward manager so + // it makes sense as there are 0s in the account data already + has_been_just_vacated: false, + } + } +} + +impl PoolRewardManager { + #[inline(never)] + pub(crate) fn unpack_to_box(input: &[u8]) -> Result, ProgramError> { + Ok(Box::new(PoolRewardManager::unpack_from_slice(input)?)) + } +} + +impl Sealed for PoolRewardManager {} + +impl Pack for PoolRewardManager { + /// total_shares + last_update_time_secs + pool_rewards. + const LEN: usize = 8 + 8 + MAX_REWARDS * PoolReward::LEN; + + fn pack_into_slice(&self, output: &mut [u8]) { + output[0..8].copy_from_slice(&self.total_shares.to_le_bytes()); + output[8..16].copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + + let rewards_to_pack = self + .pool_rewards + .iter() + .enumerate() + .filter(|(_, s)| s.should_be_packed()); + + for (index, pool_reward_slot) in rewards_to_pack { + let offset = 16 + index * PoolReward::LEN; + + let raw_pool_reward_head = array_mut_ref![output, offset, PoolReward::HEAD_LEN]; + let (dst_id, dst_vault) = + mut_array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; + + match pool_reward_slot { + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(id), + .. + } => { + dst_id.copy_from_slice(&id.to_le_bytes()); + dst_vault.copy_from_slice(Pubkey::default().as_ref()); + } + PoolRewardSlot::Occupied(pool_reward) => { + dst_id.copy_from_slice(&pool_reward.id.0.to_le_bytes()); + dst_vault.copy_from_slice(pool_reward.vault.as_ref()); + + let raw_pool_reward_tail = + array_mut_ref![output, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + dst_start_time_secs, + dst_duration_secs, + dst_total_rewards, + dst_num_user_reward_managers, + dst_cumulative_rewards_per_share_wads, + ) = mut_array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + *dst_start_time_secs = pool_reward.start_time_secs.to_le_bytes(); + *dst_duration_secs = pool_reward.duration_secs.to_le_bytes(); + *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); + *dst_num_user_reward_managers = + pool_reward.num_user_reward_managers.to_le_bytes(); + // TBD: do we want to ceil? + pack_decimal( + pool_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share_wads, + ); + } + }; + } + } + + #[inline(never)] + fn unpack_from_slice(input: &[u8]) -> Result { + let mut pool_reward_manager = PoolRewardManager { + total_shares: u64::from_le_bytes(*array_ref![input, 0, 8]), + last_update_time_secs: u64::from_le_bytes(*array_ref![input, 8, 8]), + ..Default::default() + }; + + for index in 0..MAX_REWARDS { + let offset = 8 + 8 + index * PoolReward::LEN; + let raw_pool_reward_head = array_ref![input, offset, PoolReward::HEAD_LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let (src_id, src_vault) = + array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; + + let pool_reward_id = PoolRewardId(u32::from_le_bytes(*src_id)); + let vault = Pubkey::new_from_array(*src_vault); + + // SAFETY: ok to assign because we know the index is less than length + pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { + PoolRewardSlot::Vacant { + last_pool_reward_id: pool_reward_id, + // nope, has been vacant since unpack + has_been_just_vacated: false, + } + } else { + let raw_pool_reward_tail = + array_ref![input, offset + PoolReward::HEAD_LEN, PoolReward::TAIL_LEN]; + + let ( + src_start_time_secs, + src_duration_secs, + src_total_rewards, + src_num_user_reward_managers, + src_cumulative_rewards_per_share_wads, + ) = array_refs![ + raw_pool_reward_tail, + 8, // start_time_secs + 4, // duration_secs + 8, // total_rewards + 8, // num_user_reward_managers + 16 // cumulative_rewards_per_share + ]; + + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: pool_reward_id, + vault, + start_time_secs: u64::from_le_bytes(*src_start_time_secs), + duration_secs: u32::from_le_bytes(*src_duration_secs), + total_rewards: u64::from_le_bytes(*src_total_rewards), + num_user_reward_managers: u64::from_le_bytes(*src_num_user_reward_managers), + cumulative_rewards_per_share: unpack_decimal( + src_cumulative_rewards_per_share_wads, + ), + })) + }; + } + + Ok(pool_reward_manager) + } +} + +impl PoolRewardSlot { + /// If we know for sure that data hasn't changed then we can just skip packing. + fn should_be_packed(&self) -> bool { + let for_sure_has_not_changed = matches!( + self, + Self::Vacant { + has_been_just_vacated: false, + .. + } + ); + + !for_sure_has_not_changed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + impl PoolRewardManager { + pub(crate) fn new_rand(rng: &mut impl rand::Rng) -> Self { + Self { + total_shares: rng.gen(), + last_update_time_secs: rng.gen(), + pool_rewards: std::array::from_fn(|_| { + let is_vacant = rng.gen_bool(0.5); + + if is_vacant { + PoolRewardSlot::Vacant { + last_pool_reward_id: Default::default(), + has_been_just_vacated: false, + } + } else { + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(rng.gen()), + vault: Pubkey::new_unique(), + start_time_secs: rng.gen(), + duration_secs: rng.gen(), + total_rewards: rng.gen(), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + num_user_reward_managers: rng.gen(), + })) + } + }), + } + } + } + + #[test] + fn it_packs_id_if_vacated_in_this_tx() { + let mut m = PoolRewardManager::default(); + m.pool_rewards[0] = PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_just_vacated: true, + }; + + let mut packed = vec![0u8; PoolRewardManager::LEN]; + m.pack_into_slice(&mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + + assert_eq!( + unpacked.pool_rewards[0], + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(69), + has_been_just_vacated: false, + } + ); + } + + #[test] + fn it_unpacks_empty_pool_reward_manager_bytes_as_default() { + let packed = vec![0u8; PoolRewardManager::LEN]; + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + assert_eq!(unpacked, PoolRewardManager::default()); + + // sanity check that everything starts at 0 + let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| { + matches!( + pool_reward, + PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(0), + has_been_just_vacated: false, + } + ) + }); + + assert!(all_rewards_are_empty); + } + + #[test] + fn it_fits_reserve_realloc_into_single_ix() { + const MAX_REALLOC: usize = solana_program::entrypoint::MAX_PERMITTED_DATA_INCREASE; + + let size_of_discriminant = 1; + let required_realloc = size_of_discriminant * PoolRewardManager::LEN; + assert!(required_realloc <= MAX_REALLOC); + } + + fn pool_reward_manager_strategy() -> impl Strategy { + (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) + } + + proptest! { + #[test] + fn it_packs_and_unpacks_pool_reward_manager(pool_reward_manager in pool_reward_manager_strategy()) { + let mut packed = vec![0u8; PoolRewardManager::LEN]; + Pack::pack_into_slice(&pool_reward_manager, &mut packed); + let unpacked = PoolRewardManager::unpack_from_slice(&packed).unwrap(); + + prop_assert_eq!(pool_reward_manager.last_update_time_secs, unpacked.last_update_time_secs); + prop_assert_eq!(pool_reward_manager.total_shares, unpacked.total_shares); + + for (og, unpacked) in pool_reward_manager.pool_rewards.iter().zip(unpacked.pool_rewards.iter()) { + prop_assert_eq!(og, unpacked); + } + } + } +} diff --git a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs new file mode 100644 index 00000000000..b94dba18755 --- /dev/null +++ b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs @@ -0,0 +1,605 @@ +//! [UserRewardManager]s are stored in [crate::state::Obligation]s for each +//! reserve the user has borrowed from or deposited into at the current time or +//! in the past. + +use crate::{ + error::LendingError, + math::{Decimal, TryAdd, TryMul, TrySub}, + state::{ + pack_decimal, unpack_decimal, PoolRewardId, PoolRewardManager, PoolRewardSlot, + PositionKind, MAX_REWARDS, + }, +}; +use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; +use core::{ + convert::TryInto, + ops::{Deref, DerefMut}, +}; +use solana_program::{ + clock::Clock, + msg, + program_error::ProgramError, + pubkey::{Pubkey, PUBKEY_BYTES}, +}; + +/// Wraps over user reward managers and allows mutable access to them while +/// other obligation fields are borrowed. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UserRewardManagers(Vec); + +/// Tracks user's LM rewards for a specific pool (reserve.) +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct UserRewardManager { + /// Links this manager to a reserve. + pub reserve: Pubkey, + /// Although a user cannot both borrow and deposit in the same reserve, they + /// can deposit, withdraw and then borrow the same reserve. + /// Meanwhile they could've accumulated some rewards that'd be lost. + /// + /// Also, have an explicit distinguish between borrow and deposit doesn't + /// suffer from a footgun of misattributing rewards. + pub position_kind: PositionKind, + /// For deposits, this is the amount of collateral token user has in + /// their obligation deposit. + /// + /// For borrows, this is (borrow_amount / cumulative_borrow_rate) user + /// has in their obligation borrow. + pub share: u64, + /// Monotonically increasing time taken from clock sysvar. + pub last_update_time_secs: u64, + /// The indices on [Self::rewards] are _not_ correlated with + /// [PoolRewardManager::pool_rewards]. + /// Instead, this vector only tracks meaningful rewards for the user. + /// See [UserReward::pool_reward_index]. + /// + /// This is a diversion from the Suilend implementation. + pub rewards: Vec, +} + +/// Track user rewards for a specific [PoolReward]. +#[derive(Debug, PartialEq, Eq, Default, Clone)] +pub struct UserReward { + /// Which [PoolReward] within the reserve's index does this [UserReward] + /// correspond to. + /// + /// # (Un)packing + /// There are ever only going to be at most [MAX_REWARDS]. + /// We therefore pack this value into a byte. + pub pool_reward_index: usize, + /// Each pool reward gets an ID which is monotonically increasing with each + /// new reward added to the pool. + pub pool_reward_id: PoolRewardId, + /// Before [UserReward.cumulative_rewards_per_share] is copied we find + /// time difference between current global rewards and last user update + /// rewards: + /// [PoolReward.cumulative_rewards_per_share] - [UserReward.cumulative_rewards_per_share] + /// + /// Then, we multiply that difference by [UserRewardManager.share] and + /// add the result to this counter. + pub earned_rewards: Decimal, + /// copied from [PoolReward.cumulative_rewards_per_share] at the time of the last update + pub cumulative_rewards_per_share: Decimal, +} + +/// When creating a new [UserRewardManager] we need to know whether we should +/// populate it with rewards or not. +enum CreatingNewUserRewardManager { + /// If we are creating a [UserRewardManager] then we want to populate it. + Yes, + /// If we are updating an existing [UserRewardManager] then we don't want + /// to populate it. + No, +} + +impl UserRewardManagers { + /// Returns [UserRewardManager] for the given reserve if any + pub fn find_mut( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + ) -> Option<&mut UserRewardManager> { + self.0.iter_mut().find(|user_reward_manager| { + user_reward_manager.reserve == reserve + && user_reward_manager.position_kind == position_kind + }) + } + + /// Updates the [UserRewardManager] for the given reserve. + /// + /// The caller must make sure that the provided [PoolRewardManager] is valid + /// for the given reserve. + /// + /// If an associated [UserRewardManager] is not found, it will be created. + /// + /// # Important + /// + /// Only call this if you're sure that the obligation should be tracking + /// rewards for the given reserve. + pub fn set_share( + &mut self, + reserve: Pubkey, + position_kind: PositionKind, + pool_reward_manager: &mut PoolRewardManager, + new_share: u64, + clock: &Clock, + ) -> Result<(), ProgramError> { + let user_reward_manager = if let Some(user_reward_manager) = + self.find_mut(reserve, position_kind) + { + user_reward_manager.update( + pool_reward_manager, + clock, + CreatingNewUserRewardManager::No, + )?; + user_reward_manager + } else { + let mut new_user_reward_manager = UserRewardManager::new(reserve, position_kind, clock); + new_user_reward_manager.populate(pool_reward_manager, clock)?; + self.0.push(new_user_reward_manager); + // SAFETY: we just pushed a new item to the vector so ok to unwrap + self.0.last_mut().unwrap() + }; + + user_reward_manager.set_share(pool_reward_manager, new_share); + + Ok(()) + } +} + +impl UserRewardManager { + /// Claims all rewards that the user has earned. + /// Returns how many tokens should be transferred to the user. + /// + /// # Note + /// + /// Errors if there is no pool reward with this vault. + pub fn claim_rewards( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + vault: Pubkey, + clock: &Clock, + ) -> Result { + self.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; + + let (pool_reward_index, pool_reward) = pool_reward_manager + .pool_rewards + .iter_mut() + .enumerate() + .find_map(move |(index, slot)| match slot { + PoolRewardSlot::Occupied(pool_reward) if pool_reward.vault == vault => { + Some((index, pool_reward)) + } + _ => None, + }) + .ok_or(LendingError::NoPoolRewardMatches)?; + + let Some((user_reward_index, user_reward)) = + self.rewards + .iter_mut() + .enumerate() + .find(|(_, user_reward)| { + user_reward.pool_reward_index == pool_reward_index + && user_reward.pool_reward_id == pool_reward.id + }) + else { + // User is not tracking this reward, nothing to claim. + // Let's be graceful and make this a no-op. + // Prevents failures when multiple parties crank rewards. + return Ok(0); + }; + + let to_claim = user_reward.withdraw_earned_rewards()?; + + if pool_reward.has_ended(clock) && user_reward.earned_rewards.try_floor_u64()? == 0 { + // This reward won't be used anymore as it ended and the user + // claimed all there was to claim. + // We can clean up this user reward. + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist. + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; + } + + Ok(to_claim) + } +} + +impl UserRewardManager { + /// [Self] is dynamically sized based on how many [PoolReward]s are there + /// for the given [Self::reserve]. + /// + /// This is the maximum length a manager can have. + pub(crate) const MAX_LEN: usize = Self::HEAD_LEN + MAX_REWARDS * UserReward::LEN; + + /// Length of data before [Self::rewards] tail. + /// + /// - [Self::reserve] + /// - [Self::position_kind] + /// - [Self::share] + /// - [Self::last_update_time_secs] + /// - [Self::rewards] vector length as u8 + const HEAD_LEN: usize = PUBKEY_BYTES + 1 + 8 + 8 + 1; + + /// Creates a new empty [UserRewardManager] for the given reserve. + pub(crate) fn new(reserve: Pubkey, position_kind: PositionKind, clock: &Clock) -> Self { + Self { + reserve, + last_update_time_secs: clock.unix_timestamp as _, + position_kind, + share: 0, + rewards: Vec::new(), + } + } + + /// Sets new share value for this manager. + pub(crate) fn set_share( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + new_share: u64, + ) { + msg!( + "For reserve {} there are {} total shares. \ + User's previous position was at {} and new is at {}", + self.reserve, + pool_reward_manager.total_shares, + self.share, + new_share + ); + + // This works even for migrations. + // User's old share is 0 although it shouldn't be bcs they have borrowed + // or deposited. + // We only now attribute the share to the user which is fine, it's as if + // they just now borrowed/deposited. + pool_reward_manager.total_shares = + pool_reward_manager.total_shares - self.share + new_share; + + self.share = new_share; + } + + /// When user borrows/deposits for a new reserve this function copies all + /// reserve rewards from the pool manager to the user manager and starts + /// accruing rewards. + /// + /// Invoker must have checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + pub(crate) fn populate( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + ) -> Result<(), ProgramError> { + self.update( + pool_reward_manager, + clock, + CreatingNewUserRewardManager::Yes, + ) + } + + /// Should be updated before any interaction with rewards. + /// + /// # Assumption + /// Invoker has checked that this [PoolRewardManager] matches the + /// [UserRewardManager]. + fn update( + &mut self, + pool_reward_manager: &mut PoolRewardManager, + clock: &Clock, + creating_new_reward_manager: CreatingNewUserRewardManager, + ) -> Result<(), ProgramError> { + pool_reward_manager.update(clock)?; + + let curr_unix_timestamp_secs = clock.unix_timestamp as u64; + + if matches!( + creating_new_reward_manager, + CreatingNewUserRewardManager::No + ) && curr_unix_timestamp_secs == self.last_update_time_secs + { + return Ok(()); + } + + for (pool_reward_index, pool_reward) in + pool_reward_manager.pool_rewards.iter_mut().enumerate() + { + let PoolRewardSlot::Occupied(pool_reward) = pool_reward else { + // no reward to track + continue; + }; + + let maybe_user_reward = self + .rewards + .iter_mut() + .enumerate() + .find(|(_, r)| r.pool_reward_index == pool_reward_index); + + let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; + let has_ended_for_user = self.last_update_time_secs >= end_time_secs; + + match maybe_user_reward { + Some((user_reward_index, user_reward)) + if has_ended_for_user && user_reward.earned_rewards.try_floor_u64()? == 0 => + { + // Reward period ended and there's nothing to crank. + // We can clean up this user reward. + // We're fine with swap remove bcs `user_reward_index` is meaningless. + // SAFETY: We got the index from enumeration, so must exist. + self.rewards.swap_remove(user_reward_index); + pool_reward.num_user_reward_managers -= 1; + } + _ if has_ended_for_user => { + // reward period over & there are rewards yet to be cracked + } + Some((_, user_reward)) => { + // user is already accruing rewards, add the difference + + let new_reward_amount = pool_reward + .cumulative_rewards_per_share + .try_sub(user_reward.cumulative_rewards_per_share)? + .try_mul(Decimal::from(self.share))?; + + user_reward.earned_rewards = + user_reward.earned_rewards.try_add(new_reward_amount)?; + + user_reward.cumulative_rewards_per_share = + pool_reward.cumulative_rewards_per_share; + } + None if pool_reward.start_time_secs > curr_unix_timestamp_secs => { + // reward period has not started yet + } + None => { + // user did not yet start accruing rewards + + let new_user_reward = UserReward { + pool_reward_index, + pool_reward_id: pool_reward.id, + cumulative_rewards_per_share: pool_reward.cumulative_rewards_per_share, + earned_rewards: if self.last_update_time_secs <= pool_reward.start_time_secs + { + pool_reward + .cumulative_rewards_per_share + .try_mul(Decimal::from(self.share))? + } else { + debug_assert!(matches!( + creating_new_reward_manager, + CreatingNewUserRewardManager::Yes + )); + Decimal::zero() + }, + }; + + self.rewards.push(new_user_reward); + pool_reward.num_user_reward_managers += 1; + } + } + } + + self.last_update_time_secs = curr_unix_timestamp_secs; + + Ok(()) + } + + /// How many bytes are needed to pack this [UserRewardManager]. + pub(crate) fn size_in_bytes_when_packed(&self) -> usize { + Self::HEAD_LEN + self.rewards.len() * UserReward::LEN + } + + /// Because [Self] is dynamically sized we don't implement [Pack] that + /// contains a misleading const `LEN`. + /// + /// We return how many bytes were written. + pub(crate) fn pack_into_slice(&self, output: &mut [u8]) { + let raw_user_reward_manager = array_mut_ref![output, 0, UserRewardManager::HEAD_LEN]; + + let ( + dst_reserve, + dst_position_kind, + dst_share, + dst_last_update_time_secs, + dst_user_rewards_len, + ) = mut_array_refs![ + raw_user_reward_manager, + PUBKEY_BYTES, + 1, // position_kind + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + dst_reserve.copy_from_slice(self.reserve.as_ref()); + dst_position_kind.copy_from_slice(&(self.position_kind as u8).to_le_bytes()); + dst_share.copy_from_slice(&self.share.to_le_bytes()); + dst_last_update_time_secs.copy_from_slice(&self.last_update_time_secs.to_le_bytes()); + dst_user_rewards_len.copy_from_slice( + &({ + debug_assert!(MAX_REWARDS >= self.rewards.len()); + debug_assert!(u8::MAX >= MAX_REWARDS as _); + self.rewards.len() as u8 + }) + .to_le_bytes(), + ); + + for (index, user_reward) in self.rewards.iter().enumerate() { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_mut_ref![output, offset, UserReward::LEN]; + + let ( + dst_pool_reward_index, + dst_pool_reward_id, + dst_earned_rewards, + dst_cumulative_rewards_per_share, + ) = mut_array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + dst_pool_reward_id.copy_from_slice(&user_reward.pool_reward_id.0.to_le_bytes()); + pack_decimal(user_reward.earned_rewards, dst_earned_rewards); + pack_decimal( + user_reward.cumulative_rewards_per_share, + dst_cumulative_rewards_per_share, + ); + let pool_reward_index = { + assert!(user_reward.pool_reward_index < MAX_REWARDS); + assert!(MAX_REWARDS < u8::MAX as _); + // will always fit + user_reward.pool_reward_index as u8 + }; + dst_pool_reward_index.copy_from_slice(&pool_reward_index.to_le_bytes()); + } + } + + pub(crate) fn unpack_from_slice(input: &[u8]) -> Result { + #[allow(clippy::ptr_offset_with_cast)] + let raw_user_reward_manager_head = array_ref![input, 0, UserRewardManager::HEAD_LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let ( + src_reserve, + src_position_kind, + src_share, + src_last_update_time_secs, + src_user_rewards_len, + ) = array_refs![ + raw_user_reward_manager_head, + PUBKEY_BYTES, + 1, // position_kind + 8, // share + 8, // last_update_time_secs + 1 // length of rewards array that's next to come + ]; + + let reserve = Pubkey::new_from_array(*src_reserve); + let position_kind = u8::from_le_bytes(*src_position_kind).try_into()?; + let user_rewards_len = u8::from_le_bytes(*src_user_rewards_len) as _; + let share = u64::from_le_bytes(*src_share); + let last_update_time_secs = u64::from_le_bytes(*src_last_update_time_secs); + + let mut rewards = Vec::with_capacity(user_rewards_len); + for index in 0..user_rewards_len { + let offset = Self::HEAD_LEN + index * UserReward::LEN; + let raw_user_reward = array_ref![input, offset, UserReward::LEN]; + + #[allow(clippy::ptr_offset_with_cast)] + let ( + src_pool_reward_index, + src_pool_reward_id, + src_earned_rewards, + src_cumulative_rewards_per_share, + ) = array_refs![raw_user_reward, 1, PoolRewardId::LEN, 16, 16]; + + rewards.push(UserReward { + pool_reward_index: u8::from_le_bytes(*src_pool_reward_index) as _, + pool_reward_id: PoolRewardId(u32::from_le_bytes(*src_pool_reward_id)), + earned_rewards: unpack_decimal(src_earned_rewards), + cumulative_rewards_per_share: unpack_decimal(src_cumulative_rewards_per_share), + }); + } + + Ok(Self { + reserve, + position_kind, + share, + last_update_time_secs, + rewards, + }) + } +} + +impl UserReward { + /// - [UserReward::pool_reward_index] truncated to a byte + /// - [PoolRewardId] + /// - packed [Decimal] + /// - packed [Decimal] + const LEN: usize = 1 + PoolRewardId::LEN + 16 + 16; + + /// Removes all earned rewards from [Self] and returns them. + /// + /// # Note + /// Decimals are truncated to u64, dust is kept. + fn withdraw_earned_rewards(&mut self) -> Result { + let reward_amount = self.earned_rewards.try_floor_u64()?; + + if reward_amount > 0 { + self.earned_rewards = self.earned_rewards.try_sub(reward_amount.into())?; + } + + Ok(reward_amount) + } +} + +impl From> for UserRewardManagers { + fn from(user_reward_managers: Vec) -> Self { + Self(user_reward_managers) + } +} + +impl From for Vec { + fn from(user_reward_managers: UserRewardManagers) -> Self { + user_reward_managers.0 + } +} + +impl Deref for UserRewardManagers { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for UserRewardManagers { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Default for UserRewardManager { + fn default() -> Self { + Self { + reserve: Pubkey::default(), + position_kind: PositionKind::Deposit, + share: 0, + last_update_time_secs: 0, + rewards: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + impl UserRewardManager { + pub(crate) fn new_rand(rng: &mut impl rand::Rng) -> Self { + let rewards_len = rng.gen_range(0..MAX_REWARDS); + Self { + reserve: Pubkey::new_unique(), + position_kind: rng.gen_range(0..=1u8).try_into().unwrap(), + share: rng.gen(), + last_update_time_secs: rng.gen(), + rewards: std::iter::from_fn(|| { + Some(UserReward { + pool_reward_index: rng.gen_range(0..MAX_REWARDS), + pool_reward_id: PoolRewardId(rng.gen()), + earned_rewards: Decimal::from_scaled_val(rng.gen()), + cumulative_rewards_per_share: Decimal::from_scaled_val(rng.gen()), + }) + }) + .take(rewards_len) + .collect(), + } + } + } + + fn user_reward_manager_strategy() -> impl Strategy { + (0..100u32).prop_perturb(|_, mut rng| UserRewardManager::new_rand(&mut rng)) + } + + proptest! { + #[test] + fn it_packs_and_unpacks_user_reward_manager(user_reward_manager in user_reward_manager_strategy()) { + let mut packed = vec![0u8; UserRewardManager::MAX_LEN]; + user_reward_manager.pack_into_slice(&mut packed); + let unpacked = UserRewardManager::unpack_from_slice(&packed).unwrap(); + prop_assert_eq!(user_reward_manager, unpacked); + } + } +} diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index 58d63715178..b2661d7bf08 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -823,7 +823,7 @@ impl Obligation { super_unhealthy_borrow_value: unpack_decimal(super_unhealthy_borrow_value), borrowing_isolated_asset: unpack_bool(borrowing_isolated_asset)?, closeable: unpack_bool(closeable)?, - user_reward_managers: UserRewardManagers(user_reward_managers), + user_reward_managers: user_reward_managers.into(), }) } } @@ -887,11 +887,10 @@ mod test { closeable: rng.gen(), user_reward_managers: { let user_reward_managers_len = rng.gen_range(0..=MAX_OBLIGATION_RESERVES); - UserRewardManagers( - std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) - .take(user_reward_managers_len) - .collect(), - ) + std::iter::repeat_with(|| UserRewardManager::new_rand(rng)) + .take(user_reward_managers_len) + .collect::>() + .into() }, } } From 8037676a7b2d17ec2e3615571681f33de56f45eb Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Thu, 10 Apr 2025 11:14:34 +0200 Subject: [PATCH 11/19] [Liquidity Mining] BPF tests for closing and canceling rewards (10) (#209) * Adding test for cancelling reward * Testing closing pool reward * Removing outdated TODO * Fixing clippy issues --- token-lending/program/src/processor.rs | 20 ++- .../program/src/processor/liquidity_mining.rs | 77 ++++------ .../liquidity_mining/add_pool_reward.rs | 31 +++- .../liquidity_mining/cancel_pool_reward.rs | 48 ++++-- .../liquidity_mining/claim_user_reward.rs | 48 ++++-- .../liquidity_mining/close_pool_reward.rs | 65 +++++--- .../program/tests/add_pool_reward.rs | 12 +- .../program/tests/cancel_pool_reward.rs | 145 ++++++++++++++++++ .../program/tests/close_pool_reward.rs | 126 +++++++++++++++ .../tests/helpers/solend_program_test.rs | 105 +++++++++++-- token-lending/sdk/src/instruction.rs | 141 ++++++++++++++--- .../sdk/src/state/liquidity_mining.rs | 12 +- 12 files changed, 684 insertions(+), 146 deletions(-) create mode 100644 token-lending/program/tests/cancel_pool_reward.rs create mode 100644 token-lending/program/tests/close_pool_reward.rs diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 55e58f04587..93c6bbf3734 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -208,6 +208,7 @@ pub fn process_instruction( process_donate_to_reserve(program_id, liquidity_amount, accounts) } LendingInstruction::AddPoolReward { + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -216,6 +217,7 @@ pub fn process_instruction( msg!("Instruction: Add Pool Reward"); liquidity_mining::add_pool_reward::process( program_id, + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -224,32 +226,42 @@ pub fn process_instruction( ) } LendingInstruction::CancelPoolReward { + reward_authority_bump, position_kind, pool_reward_index, } => { msg!("Instruction: Cancel Pool Reward"); liquidity_mining::cancel_pool_reward::process( program_id, + reward_authority_bump, position_kind, - pool_reward_index, + pool_reward_index as _, accounts, ) } LendingInstruction::ClosePoolReward { + reward_authority_bump, position_kind, pool_reward_index, } => { msg!("Instruction: Close Pool Reward"); liquidity_mining::close_pool_reward::process( program_id, + reward_authority_bump, position_kind, - pool_reward_index, + pool_reward_index as _, accounts, ) } - LendingInstruction::ClaimReward => { + LendingInstruction::ClaimReward { + reward_authority_bump, + } => { msg!("Instruction: Claim Reward"); - liquidity_mining::claim_user_reward::process(program_id, accounts) + liquidity_mining::claim_user_reward::process( + program_id, + reward_authority_bump, + accounts, + ) } // temporary ix for upgrade diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index df616720468..8c697ebb4ea 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -7,9 +7,9 @@ //! implementation of the same feature. //! //! There are three admin-only ixs: -//! - [add_pool_reward] (TODO: add bpf tests) -//! - [cancel_pool_reward] (TODO: add bpf tests) -//! - [close_pool_reward] (TODO: add bpf tests) +//! - [add_pool_reward] +//! - [cancel_pool_reward] +//! - [close_pool_reward] //! //! There is an ix related to migration: //! - [upgrade_reserve] (TODO: add bpf tests) @@ -27,42 +27,27 @@ pub(crate) mod upgrade_reserve; use solana_program::program_pack::Pack; use solana_program::{account_info::AccountInfo, msg, program_error::ProgramError, pubkey::Pubkey}; +use solend_sdk::instruction::create_reward_vault_authority; use solend_sdk::{error::LendingError, state::LendingMarket}; use spl_token::state::Account as TokenAccount; use super::ReserveBorrow; +struct Bumps { + reward_authority: u8, +} /// Unpacks a spl_token [TokenAccount]. fn unpack_token_account(data: &[u8]) -> Result { TokenAccount::unpack(data).map_err(|_| LendingError::InvalidTokenAccount) } -/// Derives the reward vault authority PDA address. -/// -/// TODO: Accept a bump seed to avoid recalculating it. -fn reward_vault_authority( - program_id: &Pubkey, - lending_market_key: &Pubkey, - reserve_key: &Pubkey, - reward_mint_key: &Pubkey, -) -> (Pubkey, u8) { - Pubkey::find_program_address( - &reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key), - program_id, - ) -} - -fn reward_vault_authority_seeds<'keys>( - lending_market_key: &'keys Pubkey, - reserve_key: &'keys Pubkey, - reward_mint_key: &'keys Pubkey, -) -> [&'keys [u8]; 4] { - [ - b"RewardVaultAuthority", - lending_market_key.as_ref(), - reserve_key.as_ref(), - reward_mint_key.as_ref(), - ] +/// Named args for [check_and_unpack_pool_reward_accounts] +struct CheckAndUnpackPoolRewardAccounts<'a, 'info> { + reserve_info: &'a AccountInfo<'info>, + reward_mint_info: &'a AccountInfo<'info>, + reward_authority_info: &'a AccountInfo<'info>, + lending_market_info: &'a AccountInfo<'info>, + token_program_info: &'a AccountInfo<'info>, } /// Does all the checks of [check_and_unpack_pool_reward_accounts] and additionally: @@ -71,21 +56,11 @@ fn reward_vault_authority_seeds<'keys>( /// * ✅ `lending_market_owner_info` matches `lending_market_info` fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( program_id: &Pubkey, - reserve_info: &'a AccountInfo<'info>, - reward_mint_info: &AccountInfo<'info>, - reward_authority_info: &AccountInfo<'info>, - lending_market_info: &AccountInfo<'info>, + bumps: Bumps, + accs: CheckAndUnpackPoolRewardAccounts<'a, 'info>, lending_market_owner_info: &AccountInfo<'info>, - token_program_info: &AccountInfo<'info>, ) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { - let (lending_market, reserve) = check_and_unpack_pool_reward_accounts( - program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, - token_program_info, - )?; + let (lending_market, reserve) = check_and_unpack_pool_reward_accounts(program_id, bumps, accs)?; if lending_market.owner != *lending_market_owner_info.key { msg!("Lending market owner does not match the lending market owner provided"); @@ -111,11 +86,14 @@ fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( /// * ✅ `reward_mint_info` belongs to the token program fn check_and_unpack_pool_reward_accounts<'a, 'info>( program_id: &Pubkey, - reserve_info: &'a AccountInfo<'info>, - reward_mint_info: &AccountInfo<'info>, - reward_authority_info: &AccountInfo<'info>, - lending_market_info: &AccountInfo<'info>, - token_program_info: &AccountInfo<'info>, + bumps: Bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + }: CheckAndUnpackPoolRewardAccounts<'a, 'info>, ) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { let reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; @@ -140,12 +118,13 @@ fn check_and_unpack_pool_reward_accounts<'a, 'info>( return Err(LendingError::InvalidTokenOwner.into()); } - let (expected_reward_vault_authority, _bump_seed) = reward_vault_authority( + let expected_reward_vault_authority = create_reward_vault_authority( program_id, lending_market_info.key, reserve_info.key, reward_mint_info.key, - ); + bumps.reward_authority, + )?; if expected_reward_vault_authority != *reward_authority_info.key { msg!("Reward vault authority does not match the expected value"); return Err(LendingError::InvalidAccountInput.into()); diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs index c0cc3fc3cc5..abcb03d6e45 100644 --- a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -1,6 +1,10 @@ //! Adds a new pool reward to a reserve. //! //! Each pool reward has a unique vault that holds the reward tokens. +//! This vault account must be created for the token program before calling this +//! ix. +//! In this ix we initialize the account as token account and transfer the +//! reward tokens to it. use crate::processor::{ assert_rent_exempt, spl_token_init_account, spl_token_transfer, TokenInitializeAccountParams, @@ -19,7 +23,8 @@ use solana_program::{ use solend_sdk::{error::LendingError, state::PositionKind}; use super::{ - check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, ReserveBorrow, + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, ReserveBorrow, }; /// Use [Self::from_unchecked_iter] to validate the accounts except for @@ -71,6 +76,7 @@ struct AddPoolRewardAccounts<'a, 'info> { /// 2. Finds an empty slot in the [Reserve]'s LM reward vector and adds it there. pub(crate) fn process( program_id: &Pubkey, + reward_authority_bump: u8, position_kind: PositionKind, start_time_secs: u64, end_time_secs: u64, @@ -81,8 +87,13 @@ pub(crate) fn process( let clock = &Clock::get()?; - let mut accounts = - AddPoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + let mut accounts = AddPoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; // 1. @@ -124,6 +135,7 @@ pub(crate) fn process( impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, + bumps: Bumps, iter: &mut impl Iterator>, ) -> Result, ProgramError> { let reserve_info = next_account_info(iter)?; @@ -138,12 +150,15 @@ impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + }, lending_market_owner_info, - token_program_info, )?; if reward_token_source_info.owner != token_program_info.key { diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs index 1a28ed2020b..12677690448 100644 --- a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs @@ -1,3 +1,9 @@ +//! Cancel a pool reward. +//! +//! This ix sets the end time of the pool reward to now are returns any +//! unallocated rewards to the admin. +//! Users will still be able to claim rewards. + use crate::processor::liquidity_mining::{ check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, }; @@ -11,9 +17,10 @@ use solana_program::{ program_error::ProgramError, pubkey::Pubkey, }; +use solend_sdk::instruction::reward_vault_authority_seeds; use solend_sdk::{error::LendingError, state::PositionKind}; -use super::{reward_vault_authority_seeds, ReserveBorrow}; +use super::{Bumps, CheckAndUnpackPoolRewardAccounts, ReserveBorrow}; /// Use [Self::from_unchecked_iter] to validate the accounts. struct CancelPoolRewardAccounts<'a, 'info> { @@ -51,12 +58,18 @@ struct CancelPoolRewardAccounts<'a, 'info> { /// 2. Transfers any unallocated rewards to the `reward_token_destination` account. pub(crate) fn process( program_id: &Pubkey, + reward_authority_bump: u8, position_kind: PositionKind, pool_reward_index: usize, accounts: &[AccountInfo], ) -> ProgramResult { - let mut accounts = - CancelPoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + let mut accounts = CancelPoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; // 1. @@ -77,11 +90,16 @@ pub(crate) fn process( destination: accounts.reward_token_destination_info.clone(), amount: unallocated_rewards, authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, - ), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + &accounts.reserve.key(), + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), token_program: accounts.token_program_info.clone(), })?; @@ -91,6 +109,7 @@ pub(crate) fn process( impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, + bump: Bumps, iter: &mut impl Iterator>, ) -> Result, ProgramError> { let reserve_info = next_account_info(iter)?; @@ -104,12 +123,15 @@ impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, + bump, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + }, lending_market_owner_info, - token_program_info, )?; if reward_token_destination_info.owner != token_program_info.key { diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index 9564a90caad..49d079c2287 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -19,11 +19,12 @@ use solana_program::{ pubkey::Pubkey, sysvar::Sysvar, }; -use solend_sdk::error::LendingError; use solend_sdk::state::{Obligation, PositionKind}; +use solend_sdk::{error::LendingError, instruction::reward_vault_authority_seeds}; use super::{ - check_and_unpack_pool_reward_accounts, reward_vault_authority_seeds, unpack_token_account, + check_and_unpack_pool_reward_accounts, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, }; /// Use [Self::from_unchecked_iter] to validate the accounts. @@ -70,10 +71,20 @@ struct ClaimUserReward<'a, 'info> { /// Eligible rewards are those that match the vault and user has earned any. /// 3. Transfers the withdrawn rewards to the user's token account. /// 4. Packs all changes into account buffers for [Obligation] and [Reserve]. -pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { +pub(crate) fn process( + program_id: &Pubkey, + reward_authority_bump: u8, + accounts: &[AccountInfo], +) -> ProgramResult { let clock = &Clock::get()?; - let mut accounts = ClaimUserReward::from_unchecked_iter(program_id, &mut accounts.iter())?; + let mut accounts = ClaimUserReward::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; let reserve_key = accounts.reserve.key(); // 1. @@ -149,11 +160,16 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR destination: accounts.obligation_owner_token_account_info.clone(), amount: total_reward_amount, authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - &reserve_key, - accounts.reward_mint_info.key, - ), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + &accounts.reserve.key(), + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), token_program: accounts.token_program_info.clone(), })?; @@ -173,6 +189,7 @@ pub(crate) fn process(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramR impl<'a, 'info> ClaimUserReward<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, + bumps: Bumps, iter: &mut impl Iterator>, ) -> Result, ProgramError> { let obligation_info = next_account_info(iter)?; @@ -186,11 +203,14 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { let (_, reserve) = check_and_unpack_pool_reward_accounts( program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, - token_program_info, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + }, )?; if obligation_info.owner != program_id { diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs index a0b29a15afb..cb0309e58ff 100644 --- a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -12,7 +12,9 @@ use solana_program::{ program_error::ProgramError, pubkey::Pubkey, }; -use solend_sdk::{error::LendingError, state::PositionKind}; +use solend_sdk::{ + error::LendingError, instruction::reward_vault_authority_seeds, state::PositionKind, +}; use spl_token::state::Account as TokenAccount; use crate::processor::{ @@ -20,8 +22,8 @@ use crate::processor::{ }; use super::{ - check_and_unpack_pool_reward_accounts_for_admin_ixs, reward_vault_authority_seeds, - unpack_token_account, ReserveBorrow, + check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, Bumps, + CheckAndUnpackPoolRewardAccounts, ReserveBorrow, }; /// Use [Self::from_unchecked_iter] to validate the accounts. @@ -64,12 +66,18 @@ struct ClosePoolRewardAccounts<'a, 'info> { /// 3. Closes reward vault token account. pub(crate) fn process( program_id: &Pubkey, + reward_authority_bump: u8, position_kind: PositionKind, pool_reward_index: usize, accounts: &[AccountInfo], ) -> ProgramResult { - let mut accounts = - ClosePoolRewardAccounts::from_unchecked_iter(program_id, &mut accounts.iter())?; + let mut accounts = ClosePoolRewardAccounts::from_unchecked_iter( + program_id, + Bumps { + reward_authority: reward_authority_bump, + }, + &mut accounts.iter(), + )?; // 1. @@ -84,30 +92,43 @@ pub(crate) fn process( // 2. + let bump_seed = [reward_authority_bump]; + let signer_seeds = [ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + accounts.reserve_info.key, + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&bump_seed], + ] + .concat(); + + msg!( + "Transferring {} reward tokens to {}", + accounts.reward_token_vault.amount, + accounts.reward_token_destination_info.key + ); spl_token_transfer(TokenTransferParams { source: accounts.reward_token_vault_info.clone(), destination: accounts.reward_token_destination_info.clone(), amount: accounts.reward_token_vault.amount, authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, - ), + authority_signer_seeds: &signer_seeds, token_program: accounts.token_program_info.clone(), })?; // 3. + msg!( + "Closing reward token vault {}", + accounts.reward_token_vault_info.key + ); spl_token_close_account(TokenCloseAccountParams { account: accounts.reward_token_vault_info.clone(), destination: accounts.lending_market_owner_info.clone(), authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &reward_vault_authority_seeds( - accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, - ), + authority_signer_seeds: &signer_seeds, token_program: accounts.token_program_info.clone(), })?; @@ -117,6 +138,7 @@ pub(crate) fn process( impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, + bumps: Bumps, iter: &mut impl Iterator>, ) -> Result, ProgramError> { let reserve_info = next_account_info(iter)?; @@ -130,12 +152,15 @@ impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { let (_, reserve) = check_and_unpack_pool_reward_accounts_for_admin_ixs( program_id, - reserve_info, - reward_mint_info, - reward_authority_info, - lending_market_info, + bumps, + CheckAndUnpackPoolRewardAccounts { + reserve_info, + reward_mint_info, + reward_authority_info, + lending_market_info, + token_program_info, + }, lending_market_owner_info, - token_program_info, )?; if reward_token_destination_info.owner != token_program_info.key { diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs index d2408ccdbc5..3e9f6f8351b 100644 --- a/token-lending/program/tests/add_pool_reward.rs +++ b/token-lending/program/tests/add_pool_reward.rs @@ -15,16 +15,16 @@ use solend_program::{ use solend_sdk::state::{PoolReward, PoolRewardSlot, UserReward}; #[tokio::test] -async fn test_success_for_deposit() { - test_success(PositionKind::Deposit).await; +async fn test_add_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; } #[tokio::test] -async fn test_success_for_borrow() { - test_success(PositionKind::Borrow).await; +async fn test_add_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; } -async fn test_success(position_kind: PositionKind) { +async fn test_(position_kind: PositionKind) { let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, user) = setup_world(&test_reserve_config(), &test_reserve_config()).await; @@ -62,7 +62,7 @@ async fn test_success(position_kind: PositionKind) { total_shares: 0, last_update_time_secs: current_time as _, pool_rewards: { - let mut og = usdc_reserve_post + let mut og = usdc_reserve .account .deposits_pool_reward_manager .pool_rewards diff --git a/token-lending/program/tests/cancel_pool_reward.rs b/token-lending/program/tests/cancel_pool_reward.rs new file mode 100644 index 00000000000..7bd5b1caca3 --- /dev/null +++ b/token-lending/program/tests/cancel_pool_reward.rs @@ -0,0 +1,145 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::{Keypair, Signer}; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}, +}; +use solend_sdk::state::{PoolReward, PoolRewardSlot}; + +#[tokio::test] +async fn test_cancel_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_cancel_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + let mut clock = test.get_clock().await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = clock.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let balance_checker = BalanceChecker::start( + &mut test, + &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], + ) + .await; + + clock.unix_timestamp += duration_secs as i64 / 2; + test.context.set_sysvar(&clock); + let time_when_cancelling = clock.unix_timestamp as u64; + + let pool_reward_index = 0; + lending_market + .cancel_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + ) + .await + .expect("Should cancel pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -(total_rewards as i128) / 2, + }, + TokenBalanceChange { + token_account: lending_market_owner.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff: (total_rewards as i128) / 2, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: time_when_cancelling as _, + pool_rewards: { + let mut og = usdc_reserve + .account + .deposits_pool_reward_manager + .pool_rewards + .clone(); + + og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs: duration_secs / 2, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} diff --git a/token-lending/program/tests/close_pool_reward.rs b/token-lending/program/tests/close_pool_reward.rs new file mode 100644 index 00000000000..155af89836a --- /dev/null +++ b/token-lending/program/tests/close_pool_reward.rs @@ -0,0 +1,126 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::Keypair; +use solend_program::state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}; +use solend_sdk::state::PoolRewardSlot; + +#[tokio::test] +async fn test_close_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_close_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + let mut clock = test.get_clock().await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = clock.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let balance_checker = BalanceChecker::start(&mut test, &[&lending_market_owner]).await; + + // doesn't matter when we close as long as there are no obligations + clock.unix_timestamp += 1; + test.context.set_sysvar(&clock); + + let pool_reward_index = 0; + lending_market + .close_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + ) + .await + .expect("Should close pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([TokenBalanceChange { + token_account: lending_market_owner.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff: total_rewards as _, + }]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: initial_time as _, + pool_rewards: { + let mut og = usdc_reserve + .account + .deposits_pool_reward_manager + .pool_rewards + .clone(); + + og[0] = PoolRewardSlot::Vacant { + last_pool_reward_id: PoolRewardId(1), + has_been_just_vacated: false, + }; + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 95728089c6b..d50e4ea6e51 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -75,6 +75,8 @@ mod cu_budgets { pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; pub(super) const REDEEM: u32 = 90_016; pub(super) const ADD_POOL_REWARD: u32 = 80_017; + pub(super) const CANCEL_POOL_REWARD: u32 = 80_018; + pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; } /// This is at most how many bytes can an obligation grow. @@ -919,17 +921,26 @@ impl Info { &self, test: &mut SolendProgramTest, reserve: &Info, - user: &mut User, + lending_market_owner: &mut User, reward: &LiqMiningReward, position_kind: PositionKind, start_time_secs: u64, end_time_secs: u64, reward_amount: u64, ) -> Result<(), BanksClientError> { - let token_account = user.create_token_account(&reward.mint, test).await; + let token_account = lending_market_owner + .create_token_account(&reward.mint, test) + .await; test.mint_to(&reward.mint, &token_account.pubkey, reward_amount) .await; + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ); + let instructions = [ ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::ADD_POOL_REWARD), system_instruction::create_account( @@ -941,6 +952,7 @@ impl Info { ), add_pool_reward( solend_program::id(), + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -948,20 +960,91 @@ impl Info { reserve.pubkey, reward.mint, token_account.pubkey, - find_reward_vault_authority( - &solend_program::id(), - &self.pubkey, - &reserve.pubkey, - &reward.mint, - ) - .0, + reward_authority_pda, reward.vault.pubkey(), self.pubkey, - user.keypair.pubkey(), + lending_market_owner.keypair.pubkey(), ), ]; - test.process_transaction(&instructions, Some(&[&user.keypair, &reward.vault])) + test.process_transaction( + &instructions, + Some(&[&lending_market_owner.keypair, &reward.vault]), + ) + .await + } + + pub async fn cancel_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + lending_market_owner: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + pool_reward_index: u64, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CANCEL_POOL_REWARD), + cancel_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + pool_reward_index, + reserve.pubkey, + reward.mint, + lending_market_owner.get_account(&reward.mint).unwrap(), + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + lending_market_owner.keypair.pubkey(), + ), + ]; + + test.process_transaction(&instructions, Some(&[&lending_market_owner.keypair])) + .await + } + + pub async fn close_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info, + lending_market_owner: &mut User, + reward: &LiqMiningReward, + position_kind: PositionKind, + pool_reward_index: u64, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLOSE_POOL_REWARD), + close_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + pool_reward_index, + reserve.pubkey, + reward.mint, + lending_market_owner.get_account(&reward.mint).unwrap(), + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + lending_market_owner.keypair.pubkey(), + ), + ]; + + test.process_transaction(&instructions, Some(&[&lending_market_owner.keypair])) .await } diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 4a69d5d82b2..5b3e9d034ec 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -550,6 +550,8 @@ pub enum LendingInstruction { /// `[]` Rent sysvar. /// `[]` Token program. AddPoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// If in the past according to the Clock sysvar then started immediately. @@ -580,10 +582,12 @@ pub enum LendingInstruction { /// `[signer]` Lending market owner. /// `[]` Token program. ClosePoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// Identifies a reward within a reserve's deposits/borrows rewards. - pool_reward_index: usize, + pool_reward_index: u64, }, // 27 @@ -606,10 +610,12 @@ pub enum LendingInstruction { /// `[signer]` Lending market owner. /// `[]` Token program. CancelPoolReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// Identifies a reward within a reserve's deposits/borrows rewards. - pool_reward_index: usize, + pool_reward_index: u64, }, /// 28 @@ -629,7 +635,10 @@ pub enum LendingInstruction { /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[]` Token program. - ClaimReward, + ClaimReward { + /// The bump seed of the reward authority. + reward_authority_bump: u8, + }, // 255 /// UpgradeReserveToV2_1_0 @@ -901,11 +910,13 @@ impl LendingInstruction { Self::DonateToReserve { liquidity_amount } } 25 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; let (start_time_secs, rest) = Self::unpack_u64(rest)?; let (end_time_secs, rest) = Self::unpack_u64(rest)?; let (token_amount, _rest) = Self::unpack_u64(rest)?; Self::AddPoolReward { + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -913,22 +924,31 @@ impl LendingInstruction { } } 26 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; Self::ClosePoolReward { + reward_authority_bump, position_kind, pool_reward_index: pool_reward_index as _, } } 27 => { + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; Self::CancelPoolReward { + reward_authority_bump, position_kind, pool_reward_index: pool_reward_index as _, } } - 28 => Self::ClaimReward, + 28 => { + let (reward_authority_bump, _rest) = Self::unpack_u8(rest)?; + Self::ClaimReward { + reward_authority_bump, + } + } 255 => Self::UpgradeReserveToV2_1_0, _ => { msg!("Instruction cannot be unpacked"); @@ -1239,35 +1259,44 @@ impl LendingInstruction { buf.extend_from_slice(&liquidity_amount.to_le_bytes()); } Self::AddPoolReward { + reward_authority_bump, position_kind, start_time_secs, end_time_secs, token_amount, } => { buf.push(25); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&start_time_secs.to_le_bytes()); buf.extend_from_slice(&end_time_secs.to_le_bytes()); buf.extend_from_slice(&token_amount.to_le_bytes()); } Self::ClosePoolReward { + reward_authority_bump, position_kind, pool_reward_index, } => { buf.push(26); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); } Self::CancelPoolReward { + reward_authority_bump, position_kind, pool_reward_index, } => { buf.push(27); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); } - Self::ClaimReward => { + Self::ClaimReward { + reward_authority_bump, + } => { buf.push(28); + buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); } Self::UpgradeReserveToV2_1_0 => { buf.push(255); @@ -2105,32 +2134,34 @@ pub fn upgrade_reserve_to_v2_1_0( #[allow(clippy::too_many_arguments)] pub fn add_pool_reward( program_id: Pubkey, + reward_authority_bump: u8, position_kind: PositionKind, start_time_secs: u64, end_time_secs: u64, token_amount: u64, - reserve_pubkey: Pubkey, - reward_mint_pubkey: Pubkey, - source_reward_token_account_pubkey: Pubkey, - reward_vault_authority_pubkey: Pubkey, - reward_vault_pubkey: Pubkey, - lending_market_pubkey: Pubkey, - lending_market_owner_pubkey: Pubkey, + reserve: Pubkey, + reward_mint: Pubkey, + source_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, ) -> Instruction { Instruction { program_id, accounts: vec![ - AccountMeta::new(reserve_pubkey, false), - AccountMeta::new(reward_mint_pubkey, false), - AccountMeta::new(source_reward_token_account_pubkey, false), - AccountMeta::new(reward_vault_authority_pubkey, false), - AccountMeta::new(reward_vault_pubkey, false), - AccountMeta::new_readonly(lending_market_pubkey, false), - AccountMeta::new_readonly(lending_market_owner_pubkey, true), + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(source_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(lending_market_owner, true), AccountMeta::new_readonly(sysvar::rent::id(), false), AccountMeta::new_readonly(spl_token::id(), false), ], data: LendingInstruction::AddPoolReward { + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -2140,6 +2171,78 @@ pub fn add_pool_reward( } } +/// Creates a `CancelPoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn cancel_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: u64, + reserve: Pubkey, + reward_mint: Pubkey, + destination_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(destination_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(lending_market_owner, true), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::CancelPoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + } + .pack(), + } +} + +/// Creates a `ClosePoolReward` instruction +#[allow(clippy::too_many_arguments)] +pub fn close_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + pool_reward_index: u64, + reserve: Pubkey, + reward_mint: Pubkey, + destination_reward_token_account: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, + lending_market_owner: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new(destination_reward_token_account, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new(lending_market_owner, true), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::ClosePoolReward { + reward_authority_bump, + position_kind, + pool_reward_index, + } + .pack(), + } +} + /// Derives the reward vault authority PDA address. pub fn find_reward_vault_authority( program_id: &Pubkey, diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 4698b9a62cf..fe561e78d91 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -6,8 +6,16 @@ pub mod user_reward_manager; pub use pool_reward_manager::*; pub use user_reward_manager::*; -/// Determines the size of [PoolRewardManager] -pub const MAX_REWARDS: usize = 50; +/// Determines the size of [PoolRewardManager]. +/// +/// On Suilend this is 50. +/// However, Sui dynamic object model let's us store more data easily. +/// In Save we're storing the data on the reserve and this means packing and +/// unpacking it frequently which negatively impacts CU limits. +/// +/// In Save, if we want to add new rewards we will crank old ones to make space +/// in the reserve. +pub const MAX_REWARDS: usize = 30; /// Cannot create a reward shorter than this. pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; From d361638bc5386cb0286f44b88cc9d073778098e4 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Thu, 10 Apr 2025 16:33:41 +0200 Subject: [PATCH 12/19] [Liquidity Mining] BPF tests for claiming reward (11) (#210) * Adds tests for claiming a reward ix * Fixing clippy complaint --- token-lending/program/src/processor.rs | 2 + .../program/src/processor/liquidity_mining.rs | 4 +- .../liquidity_mining/claim_user_reward.rs | 119 ++-- .../program/tests/add_pool_reward.rs | 14 +- .../program/tests/cancel_pool_reward.rs | 17 +- .../program/tests/claim_pool_reward.rs | 526 ++++++++++++++++++ .../program/tests/close_pool_reward.rs | 12 +- .../tests/helpers/solend_program_test.rs | 46 ++ token-lending/sdk/src/instruction.rs | 59 +- .../sdk/src/state/liquidity_mining.rs | 2 - .../liquidity_mining/pool_reward_manager.rs | 1 - token-lending/sdk/src/state/obligation.rs | 28 +- token-lending/sdk/src/state/reserve.rs | 8 + token-lending/tests/liquidity-mining.ts | 5 +- 14 files changed, 734 insertions(+), 109 deletions(-) create mode 100644 token-lending/program/tests/claim_pool_reward.rs diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 93c6bbf3734..8390619dca1 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -255,11 +255,13 @@ pub fn process_instruction( } LendingInstruction::ClaimReward { reward_authority_bump, + position_kind, } => { msg!("Instruction: Claim Reward"); liquidity_mining::claim_user_reward::process( program_id, reward_authority_bump, + position_kind, accounts, ) } diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 8c697ebb4ea..7e27bf0bd3c 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -12,10 +12,10 @@ //! - [close_pool_reward] //! //! There is an ix related to migration: -//! - [upgrade_reserve] (TODO: add bpf tests) +//! - [upgrade_reserve] (has anchor integration test) //! //! There is one user ix: -//! - [claim_user_reward] (TODO: add bpf tests) +//! - [claim_user_reward] //! //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index 49d079c2287..45dd377e619 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -74,6 +74,7 @@ struct ClaimUserReward<'a, 'info> { pub(crate) fn process( program_id: &Pubkey, reward_authority_bump: u8, + position_kind: PositionKind, accounts: &[AccountInfo], ) -> ProgramResult { let clock = &Clock::get()?; @@ -89,13 +90,57 @@ pub(crate) fn process( // 1. - let position_kind = accounts.obligation.find_position_kind(reserve_key)?; + let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - let Some(user_reward_manager) = accounts + if let Some(user_reward_manager) = accounts .obligation .user_reward_managers .find_mut(reserve_key, position_kind) - else { + { + msg!( + "Found user reward manager that was last updated at {} and has {}/{} shares", + user_reward_manager.last_update_time_secs, + user_reward_manager.share, + pool_reward_manager.total_shares + ); + + // 2. + + let total_reward_amount = user_reward_manager.claim_rewards( + pool_reward_manager, + *accounts.reward_token_vault_info.key, + clock, + )?; + + // 3. + + if total_reward_amount > 0 { + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.obligation_owner_token_account_info.clone(), + amount: total_reward_amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + &accounts.reserve.key(), + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), + token_program: accounts.token_program_info.clone(), + })?; + } + } else { + let expected_position_kind = accounts.obligation.find_position_kind(reserve_key)?; + + if expected_position_kind != position_kind { + msg!("Obligation does not have {:?} for reserve", position_kind); + return Err(LendingError::InvalidAccountInput.into()); + } + // We've checked that the obligation associates this reserve but it's // not in the user reward managers yet. // This means that the obligation hasn't been migrated to track the @@ -103,29 +148,27 @@ pub(crate) fn process( // // We'll upgrade it here. - let reserve_key = accounts.reserve.key(); - - let (pool_reward_manager, migrated_share) = match position_kind { - PositionKind::Borrow => { - let share = accounts - .obligation - .find_liquidity_in_borrows(reserve_key)? - .0 - .liability_shares()?; - - (&mut accounts.reserve.borrows_pool_reward_manager, share) - } + let migrated_share = match position_kind { + PositionKind::Borrow => accounts + .obligation + .find_liquidity_in_borrows(reserve_key)? + .0 + .liability_shares()?, PositionKind::Deposit => { - let share = accounts + accounts .obligation .find_collateral_in_deposits(reserve_key)? .0 - .deposited_amount; - - (&mut accounts.reserve.deposits_pool_reward_manager, share) + .deposited_amount } }; + msg!( + "Migrating obligation to track pool reward manager with share of {}/{}", + migrated_share, + pool_reward_manager.total_shares + ); + accounts.obligation.user_reward_managers.set_share( reserve_key, position_kind, @@ -133,46 +176,8 @@ pub(crate) fn process( migrated_share, clock, )?; - - realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; - Obligation::pack( - *accounts.obligation, - &mut accounts.obligation_info.data.borrow_mut(), - )?; - - return Ok(()); }; - let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - - // 2. - - let total_reward_amount = user_reward_manager.claim_rewards( - pool_reward_manager, - *accounts.reward_token_vault_info.key, - clock, - )?; - - // 3. - - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_vault_info.clone(), - destination: accounts.obligation_owner_token_account_info.clone(), - amount: total_reward_amount, - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &[ - reward_vault_authority_seeds( - accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, - ) - .as_slice(), - &[&[reward_authority_bump]], - ] - .concat(), - token_program: accounts.token_program_info.clone(), - })?; - // 4. realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs index 3e9f6f8351b..09407066fb4 100644 --- a/token-lending/program/tests/add_pool_reward.rs +++ b/token-lending/program/tests/add_pool_reward.rs @@ -54,7 +54,7 @@ async fn test_(position_kind: PositionKind) { let obligation = lending_market .init_obligation(&mut test, Keypair::new(), &user) .await - .expect("This should succeed"); + .expect("Should init obligation"); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; @@ -62,11 +62,7 @@ async fn test_(position_kind: PositionKind) { total_shares: 0, last_update_time_secs: current_time as _, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { id: PoolRewardId(1), @@ -102,7 +98,7 @@ async fn test_(position_kind: PositionKind) { deposit_amount, ) .await - .expect("This should succeed"); + .expect("Should deposit $USDC"); deposit_amount } @@ -124,7 +120,7 @@ async fn test_(position_kind: PositionKind) { 420_000_000, ) .await - .expect("This should succeed"); + .expect("Should borrow $wSOL"); lending_market .borrow_obligation_liquidity( @@ -136,7 +132,7 @@ async fn test_(position_kind: PositionKind) { 690, ) .await - .unwrap(); + .expect("Should borrow $USDC"); 690 } diff --git a/token-lending/program/tests/cancel_pool_reward.rs b/token-lending/program/tests/cancel_pool_reward.rs index 7bd5b1caca3..fa09068aca4 100644 --- a/token-lending/program/tests/cancel_pool_reward.rs +++ b/token-lending/program/tests/cancel_pool_reward.rs @@ -31,13 +31,12 @@ async fn test_cancel_pool_reward_for_borrow() { async fn test_(position_kind: PositionKind) { let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = setup_world(&test_reserve_config(), &test_reserve_config()).await; - let mut clock = test.get_clock().await; let reward_mint = test.create_mint_as_test_authority().await; let reward_vault = Keypair::new(); let duration_secs = 3_600; let total_rewards = 1_000_000; - let initial_time = clock.unix_timestamp as u64; + let initial_time = test.get_clock().await.unix_timestamp as u64; let reward = LiqMiningReward { mint: reward_mint, vault: reward_vault.insecure_clone(), @@ -63,9 +62,9 @@ async fn test_(position_kind: PositionKind) { ) .await; - clock.unix_timestamp += duration_secs as i64 / 2; - test.context.set_sysvar(&clock); - let time_when_cancelling = clock.unix_timestamp as u64; + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; let pool_reward_index = 0; lending_market @@ -100,13 +99,9 @@ async fn test_(position_kind: PositionKind) { let expected_reward_manager = Box::new(PoolRewardManager { total_shares: 0, - last_update_time_secs: time_when_cancelling as _, + last_update_time_secs: current_time, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { id: PoolRewardId(1), diff --git a/token-lending/program/tests/claim_pool_reward.rs b/token-lending/program/tests/claim_pool_reward.rs new file mode 100644 index 00000000000..f48322796df --- /dev/null +++ b/token-lending/program/tests/claim_pool_reward.rs @@ -0,0 +1,526 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::account::Account; +use solana_sdk::instruction::InstructionError; +use solana_sdk::signature::{Keypair, Signer}; +use solana_sdk::transaction::TransactionError; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, +}; +use solend_sdk::error::LendingError; +use solend_sdk::math::TryMul; +use solend_sdk::state::{Obligation, PoolReward, PoolRewardSlot, UserReward}; + +#[tokio::test] +async fn test_claim_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_claim_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let current_time = test.get_clock().await.unix_timestamp as u64; + let initial_time = current_time; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + current_time, + current_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + let expected_share = match position_kind { + PositionKind::Deposit => { + let deposit_amount = 1_000_000; + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + deposit_amount, + ) + .await + .expect("Should deposit $USDC"); + + deposit_amount + } + PositionKind::Borrow => { + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &wsol_reserve, + &obligation, + &user, + 420_000_000, + ) + .await + .expect("Should deposit $wSOL"); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &usdc_reserve, + &obligation, + &user, + None, + 690, + ) + .await + .expect("Should borrow $USDC"); + + 690 + } + }; + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_token_account(&reward.mint, &mut test).await; + + let balance_checker = + BalanceChecker::start(&mut test, &[&TokenAccount(reward.vault.pubkey()), &user]).await; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + ) + .await + .expect("Should claim reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let diff = (total_rewards as i128) / 2 + - match position_kind { + PositionKind::Deposit => 0, + PositionKind::Borrow => 1, // integer division rounds down + }; + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff, + }, + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -diff, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let cumulative_rewards_per_share = match position_kind { + PositionKind::Deposit => Decimal::from_scaled_val(500000000000000000), + PositionKind::Borrow => Decimal::from_scaled_val(724637681159420289855), + }; + + let expected_reward_manager = PoolRewardManager { + total_shares: expected_share, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 1, + cumulative_rewards_per_share, + })); + + og + }, + }; + + assert_eq!( + usdc_reserve_post.account.pool_reward_manager(position_kind), + &expected_reward_manager + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + let earned_rewards = match position_kind { + PositionKind::Deposit => { + // on deposit there's no division involved and so it ends up being + // nice whole number + Decimal::zero() + } + PositionKind::Borrow => { + // on borrow we have some precision loss and so the one extra + // _almost_ token stays in the user's account + Decimal::from_scaled_val(999999999999999950) + } + }; + // we don't withdraw fractions of a token but keep them around for future claims + assert_eq!(earned_rewards.try_floor_u64().unwrap(), 0); + + assert_eq!( + obligation_post.account.user_reward_managers.last().unwrap(), + &UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind, + share: expected_share, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards, + cumulative_rewards_per_share + }], + } + ); + + // move time forward so that all rewards can be claimed + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as _) + .await; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + ) + .await + .expect("Should claim reward"); + + // reserve should have no user reward managers + + let usdc_reserve_final = test.load_account::(usdc_reserve.pubkey).await; + let pool_reward_manager = usdc_reserve_final + .account + .pool_reward_manager(position_kind); + + assert_eq!(pool_reward_manager.last_update_time_secs, current_time); + + assert_eq!( + pool_reward_manager.pool_rewards[0], + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: cumulative_rewards_per_share + .try_mul(Decimal::from(2u64)) + .unwrap() + })) + ); + + // obligation should no longer track this reward + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final + .account + .user_reward_managers + .last() + .unwrap() + .rewards, + vec![], + ); +} + +#[tokio::test] +async fn test_cannot_claim_into_wrong_destination() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + // let's use a token account of a wrong user + lending_market_owner + .create_token_account(&reward.mint, &mut test) + .await; + + let err = lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &lending_market_owner, // ! wrong + &reward, + PositionKind::Deposit, + ) + .await + .expect_err("Cannot steal user reward"); + + assert_eq!( + err.unwrap(), + TransactionError::InstructionError( + 1, + InstructionError::Custom(LendingError::InvalidAccountInput as _) + ) + ); +} + +#[tokio::test] +async fn test_migrate_obligation() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + { + // The call above set up the obligation with a user reward manager. + // We'll now truncate the liq. mining data to simulate an obligation in + // the old format. + // However, that will leave the reserve in an invalid state as it will + // have already the user shares set up. + // That's ok, let's just ignore that in this test. + + let mut new_raw_obligation = Account { + data: vec![0; Obligation::MIN_LEN], + ..test + .context + .banks_client + .get_account(obligation.pubkey) + .await + .expect("Should access obligation account") + .expect("Obligation account should exist") + }; + + Obligation::pack( + { + let mut obligation = test.load_obligation(obligation.pubkey).await; + obligation.account.user_reward_managers.clear(); + obligation.account + }, + &mut new_raw_obligation.data, + ) + .expect("Should pack obligation"); + + test.context + .set_account(&obligation.pubkey, &new_raw_obligation.into()); + } + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_token_account(&reward.mint, &mut test).await; + + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + // At this point the user did not have any shares in the obligation and so + // they cannot claim anything. + // However, we migrate the obligation so that next time they claim they do + // get something. + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + ) + .await + .expect("Should claim reward"); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + assert_eq!( + usdc_reserve_post + .account + .deposits_pool_reward_manager + .total_shares, + 2 // 1 from the old obligation and 1 from the new one + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_post.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards: Decimal::zero(), + cumulative_rewards_per_share: Decimal::from_scaled_val(500000_000000000000000000), + }], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!(balance_changes, HashSet::new()); + + let current_time = test.advance_clock_by_slots_and_secs(1, duration_secs).await; + + // now they should be able to claim rewards + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + ) + .await + .expect("Should claim reward"); + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!( + balance_changes, + HashSet::from([TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + // There are 2 shares and we're accruing rewards for half the time. + // There are 2 shares bcs we reset the obligation and "register" it + // a second time in this test. + diff: (total_rewards as i128) / 4, + }]) + ); +} diff --git a/token-lending/program/tests/close_pool_reward.rs b/token-lending/program/tests/close_pool_reward.rs index 155af89836a..1493d8a8e8f 100644 --- a/token-lending/program/tests/close_pool_reward.rs +++ b/token-lending/program/tests/close_pool_reward.rs @@ -28,13 +28,12 @@ async fn test_close_pool_reward_for_borrow() { async fn test_(position_kind: PositionKind) { let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = setup_world(&test_reserve_config(), &test_reserve_config()).await; - let mut clock = test.get_clock().await; let reward_mint = test.create_mint_as_test_authority().await; let reward_vault = Keypair::new(); let duration_secs = 3_600; let total_rewards = 1_000_000; - let initial_time = clock.unix_timestamp as u64; + let initial_time = test.get_clock().await.unix_timestamp as u64; let reward = LiqMiningReward { mint: reward_mint, vault: reward_vault.insecure_clone(), @@ -57,8 +56,7 @@ async fn test_(position_kind: PositionKind) { let balance_checker = BalanceChecker::start(&mut test, &[&lending_market_owner]).await; // doesn't matter when we close as long as there are no obligations - clock.unix_timestamp += 1; - test.context.set_sysvar(&clock); + test.advance_clock_by_slots_and_secs(1, 1).await; let pool_reward_index = 0; lending_market @@ -88,11 +86,7 @@ async fn test_(position_kind: PositionKind) { total_shares: 0, last_update_time_secs: initial_time as _, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Vacant { last_pool_reward_id: PoolRewardId(1), diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index d50e4ea6e51..1f45cabdbbf 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -77,6 +77,7 @@ mod cu_budgets { pub(super) const ADD_POOL_REWARD: u32 = 80_017; pub(super) const CANCEL_POOL_REWARD: u32 = 80_018; pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; + pub(super) const CLAIM_POOL_REWARD: u32 = 80_020; } /// This is at most how many bytes can an obligation grow. @@ -337,6 +338,16 @@ impl SolendProgramTest { .await } + /// Returns the new clock unix timestamp + pub async fn advance_clock_by_slots_and_secs(&mut self, slots: u64, secs: u64) -> u64 { + self.advance_clock_by_slots(slots).await; + let mut clock = self.get_clock().await; + clock.unix_timestamp += secs as i64; + self.context.set_sysvar(&clock); + + clock.unix_timestamp as u64 + } + /// Advances clock by x slots. note that transactions don't automatically increment the slot /// value in Clock, so this function must be explicitly called whenever you want time to move /// forward. @@ -1048,6 +1059,41 @@ impl Info { .await } + pub async fn claim_pool_reward( + &self, + test: &mut SolendProgramTest, + obligation: &Info, + reserve: &Info, + obligation_owner: &User, + reward: &LiqMiningReward, + position_kind: PositionKind, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLAIM_POOL_REWARD), + claim_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + obligation.pubkey, + obligation_owner.get_account(&reward.mint).unwrap(), + reserve.pubkey, + reward.mint, + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + ), + ]; + + test.process_transaction(&instructions, None).await + } + pub async fn donate_to_reserve( &self, test: &mut SolendProgramTest, diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 5b3e9d034ec..e7e4a251b68 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -621,10 +621,10 @@ pub enum LendingInstruction { /// 28 /// ClaimReward /// - /// * User can claim rewards from their obligation. + /// * Permissionless claim of rewards from an obligation. /// /// `[writable]` Obligation account. - /// `[writable]` Obligation owner reward receiving token account. + /// `[writable]` Obligation owner's token account that receives reward. /// `[writable]` Reserve account. /// `[]` Reward mint. /// `[]` Derived reserve pool reward authority. Seed: @@ -638,6 +638,10 @@ pub enum LendingInstruction { ClaimReward { /// The bump seed of the reward authority. reward_authority_bump: u8, + /// Even though an obligation can either deposit or borrow the same + /// reserve, the obligation's rewards can hold rewards for both. + /// It's therefore necessary to specify which kind of reward to claim. + position_kind: PositionKind, }, // 255 @@ -944,9 +948,11 @@ impl LendingInstruction { } } 28 => { - let (reward_authority_bump, _rest) = Self::unpack_u8(rest)?; + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, _rest) = Self::unpack_try_from_u8(rest)?; Self::ClaimReward { reward_authority_bump, + position_kind, } } 255 => Self::UpgradeReserveToV2_1_0, @@ -1294,9 +1300,11 @@ impl LendingInstruction { } Self::ClaimReward { reward_authority_bump, + position_kind, } => { buf.push(28); buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); } Self::UpgradeReserveToV2_1_0 => { buf.push(255); @@ -2243,6 +2251,51 @@ pub fn close_pool_reward( } } +/// `[writable]` Obligation account. +/// `[writable]` Obligation owner's token account that receives reward. +/// `[writable]` Reserve account. +/// `[]` Reward mint. +/// `[]` Derived reserve pool reward authority. Seed: +/// * b"RewardVaultAuthority" +/// * Lending market account pubkey +/// * Reserve account pubkey +/// * Reward mint pubkey +/// `[writable]` Reward vault token account. +/// `[]` Lending market account. +/// `[]` Token program. +#[allow(clippy::too_many_arguments)] +pub fn claim_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + obligation: Pubkey, + obligation_owner_token_account_for_reward: Pubkey, + reserve: Pubkey, + reward_mint: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(obligation, false), + AccountMeta::new(obligation_owner_token_account_for_reward, false), + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::ClaimReward { + reward_authority_bump, + position_kind, + } + .pack(), + } +} + /// Derives the reward vault authority PDA address. pub fn find_reward_vault_authority( program_id: &Pubkey, diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index fe561e78d91..16886e522b7 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -24,8 +24,6 @@ pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; mod suilend_tests { //! These tests were taken from the Suilend's codebase and adapted to //! the new codebase. - //! - //! TODO: Calculate test coverage and add tests for missing branches. use crate::{ math::Decimal, diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs index ecfc83a9ccb..b255b88a7e6 100644 --- a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -415,7 +415,6 @@ impl Pack for PoolRewardManager { *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); *dst_num_user_reward_managers = pool_reward.num_user_reward_managers.to_le_bytes(); - // TBD: do we want to ceil? pack_decimal( pool_reward.cumulative_rewards_per_share, dst_cumulative_rewards_per_share_wads, diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index b2661d7bf08..b1c85e6e560 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -485,11 +485,11 @@ impl ObligationLiquidity { const OBLIGATION_COLLATERAL_LEN: usize = 88; // 32 + 8 + 16 + 32 const OBLIGATION_LIQUIDITY_LEN: usize = 112; // 32 + 16 + 16 + 16 + 32 /// This is the size of the account _before_ LM feature was added. -const OBLIGATION_LEN_V1: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) - // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca +const OBLIGATION_LEN_V2_0_2: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) + // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca impl Obligation { /// Obligation with no Liquidity Mining Rewards - pub const MIN_LEN: usize = OBLIGATION_LEN_V1; + pub const MIN_LEN: usize = OBLIGATION_LEN_V2_0_2; /// Maximum account size for obligation. /// Scenario in which all reserves have all associated rewards filled. @@ -502,10 +502,10 @@ impl Obligation { /// How many bytes are needed to pack this [UserRewardManager]. pub fn size_in_bytes_when_packed(&self) -> usize { if self.user_reward_managers.is_empty() { - return OBLIGATION_LEN_V1; + return OBLIGATION_LEN_V2_0_2; } - let mut size = OBLIGATION_LEN_V1 + 1; + let mut size = OBLIGATION_LEN_V2_0_2 + 1; for reward_manager in self.user_reward_managers.iter() { size += reward_manager.size_in_bytes_when_packed(); @@ -554,7 +554,7 @@ impl Obligation { /// Since @v2.1.0 we pack vec of user reward managers pub fn pack_into_slice(&self, dst: &mut [u8]) { - let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V1]; + let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( discriminator, @@ -666,17 +666,17 @@ impl Obligation { debug_assert!(MAX_OBLIGATION_RESERVES >= self.user_reward_managers.len()); debug_assert!(u8::MAX > MAX_OBLIGATION_RESERVES as _); let user_reward_managers_len = self.user_reward_managers.len() as u8; - dst[OBLIGATION_LEN_V1] = user_reward_managers_len; + dst[OBLIGATION_LEN_V2_0_2] = user_reward_managers_len; - let mut offset = OBLIGATION_LEN_V1 + 1; + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for user_reward_manager in self.user_reward_managers.iter() { user_reward_manager.pack_into_slice(&mut dst[offset..]); offset += user_reward_manager.size_in_bytes_when_packed(); } - } else if dst.len() > OBLIGATION_LEN_V1 { + } else if dst.len() > OBLIGATION_LEN_V2_0_2 { // set the length to 0 if obligation was resized before - dst[OBLIGATION_LEN_V1] = 0; + dst[OBLIGATION_LEN_V2_0_2] = 0; }; // Any data after offset is garbage, but we don't zero it out bcs @@ -686,7 +686,7 @@ impl Obligation { /// Unpacks a byte buffer into an [Obligation]. /// Since @v2.1.0 we unpack vector of user reward managers pub fn unpack_from_slice(src: &[u8]) -> Result { - let input = array_ref![src, 0, OBLIGATION_LEN_V1]; + let input = array_ref![src, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( discriminator, @@ -738,7 +738,7 @@ impl Obligation { } Err(LendingError::AccountNotMigrated) => { // We're migrating the account from v2.0.2 to v2.1.0. - debug_assert_eq!(OBLIGATION_LEN_V1, input.len()); + debug_assert_eq!(OBLIGATION_LEN_V2_0_2, input.len()); AccountDiscriminator::Obligation } @@ -788,11 +788,11 @@ impl Obligation { offset += OBLIGATION_LIQUIDITY_LEN; } - let user_reward_managers = match src.get(OBLIGATION_LEN_V1) { + let user_reward_managers = match src.get(OBLIGATION_LEN_V2_0_2) { Some(len @ 1..) => { let mut user_reward_managers = Vec::with_capacity(*len as _); - let mut offset = OBLIGATION_LEN_V1 + 1; + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for _ in 0..*len { let user_reward_manager = UserRewardManager::unpack_from_slice(&src[offset..])?; offset += user_reward_manager.size_in_bytes_when_packed(); diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index d2f4def8193..7f59f39384a 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -594,6 +594,14 @@ impl Reserve { )) } + /// Returns the pool reward manager for the given position kind + pub fn pool_reward_manager(&self, position_kind: PositionKind) -> &PoolRewardManager { + match position_kind { + PositionKind::Borrow => &self.borrows_pool_reward_manager, + PositionKind::Deposit => &self.deposits_pool_reward_manager, + } + } + /// Returns the pool reward manager for the given position kind pub fn pool_reward_manager_mut( &mut self, diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts index d3c988d6961..d8cd3aa2c8c 100644 --- a/token-lending/tests/liquidity-mining.ts +++ b/token-lending/tests/liquidity-mining.ts @@ -1,4 +1,7 @@ /** + * Temporary test to showcase that reserve upgrades work with CLI. + * We'll delete this once all reserves are upgraded. + * * $ anchor test --provider.cluster localnet --detach */ @@ -57,7 +60,7 @@ describe("liquidity mining", () => { .getProvider() .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); - expect(reserveAfter.data.length).to.eq(8651); // new version data length + expect(reserveAfter.data.length).to.eq(5451); // new version data length const expectedRentAfter = await anchor .getProvider() .connection.getMinimumBalanceForRentExemption(reserveAfter.data.length); From 0a04ecf8395900e2def37218871d98165ae6044f Mon Sep 17 00:00:00 2001 From: vanity Date: Sun, 13 Apr 2025 16:48:48 +0200 Subject: [PATCH 13/19] Improving code coverage of account checks --- token-lending/program/src/processor.rs | 2 +- .../program/src/processor/account_borrow.rs | 17 + .../program/src/processor/liquidity_mining.rs | 462 +++++++++++++++++- .../program/tests/refresh_obligation.rs | 2 +- token-lending/sdk/src/state/lending_market.rs | 4 +- .../liquidity_mining/user_reward_manager.rs | 2 +- token-lending/sdk/src/state/obligation.rs | 10 +- 7 files changed, 488 insertions(+), 11 deletions(-) diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 8390619dca1..71f1a115e60 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -3601,7 +3601,7 @@ fn realloc_obligation_if_necessary( obligation: &Obligation, obligation_info: &AccountInfo<'_>, ) -> ProgramResult { - let expected_size = obligation.size_in_bytes_when_packed(); + let expected_size = obligation.get_packed_len(); if expected_size <= obligation_info.data_len() { return Ok(()); diff --git a/token-lending/program/src/processor/account_borrow.rs b/token-lending/program/src/processor/account_borrow.rs index d3412b39262..2d812af5250 100644 --- a/token-lending/program/src/processor/account_borrow.rs +++ b/token-lending/program/src/processor/account_borrow.rs @@ -18,6 +18,7 @@ use solana_program::{ program_pack::Pack, pubkey::Pubkey, }; +use std::fmt::Debug; use std::ops::{Deref, DerefMut}; use std::result::Result; @@ -214,3 +215,19 @@ impl From<&'_ ReserveDataGuard<'_, '_>> for ReserveDataGuardKind { } } } + +impl Debug for ReserveBorrow<'_, '_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut dbg = f.debug_struct("ReserveBorrow"); + match &self.guard { + ReserveDataGuard::Released => dbg.field("variant", &"Released"), + ReserveDataGuard::Ref(_, reserve) => { + dbg.field("variant", &"Ref").field("reserve", &reserve) + } + ReserveDataGuard::RefMut(_, reserve) => { + dbg.field("variant", &"RefMut").field("reserve", &reserve) + } + } + .finish() + } +} diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 7e27bf0bd3c..6f2bfb2a94c 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -79,11 +79,11 @@ fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( /// * ✅ `reserve_info` belongs to this program /// * ✅ `reserve_info` unpacks /// * ✅ `reserve_info` belongs to `lending_market_info` -/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` /// * ✅ `lending_market_info` belongs to this program /// * ✅ `lending_market_info` unpacks /// * ✅ `token_program_info` matches `lending_market_info` /// * ✅ `reward_mint_info` belongs to the token program +/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` fn check_and_unpack_pool_reward_accounts<'a, 'info>( program_id: &Pubkey, bumps: Bumps, @@ -132,3 +132,463 @@ fn check_and_unpack_pool_reward_accounts<'a, 'info>( Ok((lending_market, reserve)) } + +#[cfg(test)] +mod tests { + //! For each ✅ in [check_and_unpack_pool_reward_accounts] and + //! [check_and_unpack_pool_reward_accounts_for_admin_ixs] there is a test + //! that expects a failure if that conditions is not met. + + use solana_program::system_program; + use solend_sdk::{ + instruction::find_reward_vault_authority, + state::{discriminator::AccountDiscriminator, Reserve}, + }; + use spl_token::state::Mint; + + use super::*; + + #[test] + fn test_check_and_unpack_pool_reward_accounts_ok() { + let (account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect("Should succeed"); + } + + /// ❌ `reserve_info` belongs to this program + #[test] + fn test_fails_if_reserve_info_does_not_belong_to_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.reserve.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reserve_info` unpacks + #[test] + fn test_fails_if_reserve_info_does_not_unpack() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.reserve.data = vec![0; Reserve::get_packed_len() - 1]; + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reserve_info` belongs to `lending_market_info` + #[test] + fn test_fails_if_reserve_info_does_not_belong_to_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders.reserve = AccountInfoBuilder::from(Reserve { + discriminator: AccountDiscriminator::Reserve, + lending_market: Pubkey::new_unique(), + ..Default::default() + }); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_info` belongs to this program + #[test] + fn test_fails_if_lending_market_info_does_not_belong_to_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_info` unpacks + #[test] + fn test_fails_if_lending_market_info_does_not_unpack() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market.data = vec![0; LendingMarket::get_packed_len() - 1]; + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `token_program_info` matches `lending_market_info` + #[test] + fn test_fails_if_token_program_info_does_not_match_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders.lending_market = AccountInfoBuilder::from(LendingMarket { + discriminator: AccountDiscriminator::LendingMarket, + token_program_id: Pubkey::new_unique(), + ..Default::default() + }); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reward_mint_info` belongs to the token program + #[test] + fn test_fails_if_reward_mint_info_does_not_belong_to_token_program() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.mint.owner = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + #[test] + fn test_fails_if_reward_authority_info_is_not_seed() { + let (mut account_info_builders, og_bumps) = + CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + let og_reward_authority = account_info_builders.reward_authority.key; + + // wrong lending market + + let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( + &crate::id(), + &Pubkey::new_unique(), + &account_info_builders.reserve.key, + &account_info_builders.mint.key, + ); + account_info_builders.reward_authority.key = new_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: new_reward_authority_bump, + }, + ) + .expect_err("Should fail"); + + // wrong reserve + + let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( + &crate::id(), + &account_info_builders.lending_market.key, + &Pubkey::new_unique(), + &account_info_builders.mint.key, + ); + account_info_builders.reward_authority.key = new_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: new_reward_authority_bump, + }, + ) + .expect_err("Should fail"); + + // wrong mint + + let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( + &crate::id(), + &account_info_builders.lending_market.key, + &account_info_builders.reserve.key, + &Pubkey::new_unique(), + ); + account_info_builders.reward_authority.key = new_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: new_reward_authority_bump, + }, + ) + .expect_err("Should fail"); + + // wrong bump + + account_info_builders.reward_authority.key = og_reward_authority; + account_info_builders + .clone() + .check_and_unpack_pool_reward_accounts( + crate::id(), + Bumps { + reward_authority: og_bumps.reward_authority.wrapping_add(1), + }, + ) + .expect_err("Should fail"); + } + + #[test] + fn test_check_and_unpack_pool_reward_accounts_for_admin_ixs_ok() { + let (account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect("Should succeed"); + } + + /// ❌ `lending_market_owner_info` is a signer + #[test] + fn test_fails_if_lending_market_owner_info_is_not_signer() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market_owner.is_signer = false; + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect_err("Should fail"); + } + + /// ❌ `lending_market_owner_info` matches `lending_market_info` + #[test] + fn test_fails_if_lending_market_owner_info_does_not_match_lending_market() { + let (mut account_info_builders, bumps) = CheckAndUnpackPoolRewardAccountInfoBuilders::new(); + account_info_builders.lending_market_owner.key = Pubkey::new_unique(); + + account_info_builders + .check_and_unpack_pool_reward_accounts_for_admin_ixs(crate::id(), bumps) + .expect_err("Should fail"); + } + + #[derive(Clone)] + struct CheckAndUnpackPoolRewardAccountInfoBuilders { + lending_market: AccountInfoBuilder, + lending_market_owner: AccountInfoBuilder, + mint: AccountInfoBuilder, + reserve: AccountInfoBuilder, + reward_authority: AccountInfoBuilder, + token_program: AccountInfoBuilder, + } + + #[derive(Clone)] + struct AccountInfoBuilder { + key: Pubkey, + lamports: u64, + data: Vec, + owner: Pubkey, + rent_epoch: u64, + is_signer: bool, + is_writable: bool, + is_executable: bool, + } + + impl CheckAndUnpackPoolRewardAccountInfoBuilders { + fn new() -> (Self, Bumps) { + let token_program = AccountInfoBuilder::new_token_program(); + let lending_market_owner = AccountInfoBuilder::new_lending_market_owner(); + let lending_market: AccountInfoBuilder = AccountInfoBuilder::from(LendingMarket { + discriminator: AccountDiscriminator::LendingMarket, + token_program_id: token_program.key, + owner: lending_market_owner.key, + ..Default::default() + }); + let mint = AccountInfoBuilder::from(Mint { + is_initialized: true, + ..Default::default() + }); + let reserve = AccountInfoBuilder::from(Reserve { + discriminator: AccountDiscriminator::Reserve, + lending_market: lending_market.key, + ..Default::default() + }); + let (reward_authority, bumps) = AccountInfoBuilder::new_reward_authority( + &lending_market.key, + &reserve.key, + &mint.key, + ); + + ( + Self { + lending_market_owner, + lending_market, + mint, + reserve, + reward_authority, + token_program, + }, + bumps, + ) + } + + fn check_and_unpack_pool_reward_accounts( + mut self, + program_id: Pubkey, + bumps: Bumps, + ) -> Result<(), ProgramError> { + let lending_market_info = self.lending_market.as_account_info(); + let mint_info = self.mint.as_account_info(); + let reserve_info = self.reserve.as_account_info(); + let reward_authority_info = self.reward_authority.as_account_info(); + let token_program_info = self.token_program.as_account_info(); + + check_and_unpack_pool_reward_accounts( + &program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + lending_market_info: &lending_market_info, + reserve_info: &reserve_info, + reward_authority_info: &reward_authority_info, + reward_mint_info: &mint_info, + token_program_info: &token_program_info, + }, + ) + .map(drop) + } + + fn check_and_unpack_pool_reward_accounts_for_admin_ixs( + mut self, + program_id: Pubkey, + bumps: Bumps, + ) -> Result<(), ProgramError> { + let lending_market_info = self.lending_market.as_account_info(); + let mint_info = self.mint.as_account_info(); + let reserve_info = self.reserve.as_account_info(); + let reward_authority_info = self.reward_authority.as_account_info(); + let token_program_info = self.token_program.as_account_info(); + let lending_market_owner_info = self.lending_market_owner.as_account_info(); + + check_and_unpack_pool_reward_accounts_for_admin_ixs( + &program_id, + bumps, + CheckAndUnpackPoolRewardAccounts { + lending_market_info: &lending_market_info, + reserve_info: &reserve_info, + reward_authority_info: &reward_authority_info, + reward_mint_info: &mint_info, + token_program_info: &token_program_info, + }, + &lending_market_owner_info, + ) + .map(drop) + } + } + + impl From for AccountInfoBuilder { + fn from(lending_market: LendingMarket) -> Self { + let mut data = vec![0; LendingMarket::get_packed_len()]; + LendingMarket::pack(lending_market, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: crate::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl From for AccountInfoBuilder { + fn from(mint: Mint) -> Self { + let mut data = vec![0; Mint::get_packed_len()]; + Mint::pack(mint, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: spl_token::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl From for AccountInfoBuilder { + fn from(reserve: Reserve) -> Self { + let mut data = vec![0; Reserve::get_packed_len()]; + Reserve::pack(reserve, &mut data).unwrap(); + + Self { + key: Pubkey::new_unique(), + lamports: 1, + data, + owner: crate::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + } + } + } + + impl AccountInfoBuilder { + fn as_account_info(&mut self) -> AccountInfo { + AccountInfo::new( + &self.key, + self.is_signer, + self.is_writable, + &mut self.lamports, + &mut self.data, + &self.owner, + self.is_executable, + self.rent_epoch, + ) + } + + fn new_token_program() -> Self { + Self { + key: spl_token::id(), + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: true, + } + } + + fn new_reward_authority( + lending_market_key: &Pubkey, + reserve_key: &Pubkey, + reward_mint_key: &Pubkey, + ) -> (Self, Bumps) { + let (key, bump) = find_reward_vault_authority( + &crate::id(), + lending_market_key, + reserve_key, + reward_mint_key, + ); + + let s = Self { + key, + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: false, + is_writable: false, + is_executable: false, + }; + + ( + s, + Bumps { + reward_authority: bump, + }, + ) + } + + fn new_lending_market_owner() -> Self { + Self { + key: Pubkey::new_unique(), + lamports: 0, + data: vec![], + owner: system_program::id(), + rent_epoch: 0, + is_signer: true, + is_writable: false, + is_executable: false, + } + } + } +} diff --git a/token-lending/program/tests/refresh_obligation.rs b/token-lending/program/tests/refresh_obligation.rs index 6c3397a897f..ac64044de4a 100644 --- a/token-lending/program/tests/refresh_obligation.rs +++ b/token-lending/program/tests/refresh_obligation.rs @@ -544,7 +544,7 @@ async fn test_normalize_obligation() { ..Obligation::default() }; - let mut packed_obligation = vec![0; obligation.size_in_bytes_when_packed()]; + let mut packed_obligation = vec![0; obligation.get_packed_len()]; obligation.pack_into_slice(&mut packed_obligation); test.add_packed( obligation_pubkey, diff --git a/token-lending/sdk/src/state/lending_market.rs b/token-lending/sdk/src/state/lending_market.rs index 3185a69caa7..2d699eaa39a 100644 --- a/token-lending/sdk/src/state/lending_market.rs +++ b/token-lending/sdk/src/state/lending_market.rs @@ -223,12 +223,12 @@ impl Pack for LendingMarket { } #[cfg(test)] -mod test { +pub(crate) mod test { use super::*; use rand::Rng; impl LendingMarket { - fn new_rand(rng: &mut impl Rng) -> Self { + pub(crate) fn new_rand(rng: &mut impl Rng) -> Self { Self { discriminator: AccountDiscriminator::LendingMarket, bump_seed: rng.gen(), diff --git a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs index b94dba18755..558bd09a936 100644 --- a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs @@ -379,7 +379,7 @@ impl UserRewardManager { } /// How many bytes are needed to pack this [UserRewardManager]. - pub(crate) fn size_in_bytes_when_packed(&self) -> usize { + pub(crate) fn get_packed_len(&self) -> usize { Self::HEAD_LEN + self.rewards.len() * UserReward::LEN } diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index b1c85e6e560..c88a95ccb59 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -499,8 +499,8 @@ impl Obligation { pub const MAX_LEN: usize = Self::MIN_LEN + 1 + MAX_OBLIGATION_RESERVES * UserRewardManager::MAX_LEN; - /// How many bytes are needed to pack this [UserRewardManager]. - pub fn size_in_bytes_when_packed(&self) -> usize { + /// How many bytes are needed to pack this [Obligation]. + pub fn get_packed_len(&self) -> usize { if self.user_reward_managers.is_empty() { return OBLIGATION_LEN_V2_0_2; } @@ -508,7 +508,7 @@ impl Obligation { let mut size = OBLIGATION_LEN_V2_0_2 + 1; for reward_manager in self.user_reward_managers.iter() { - size += reward_manager.size_in_bytes_when_packed(); + size += reward_manager.get_packed_len(); } size @@ -671,7 +671,7 @@ impl Obligation { let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for user_reward_manager in self.user_reward_managers.iter() { user_reward_manager.pack_into_slice(&mut dst[offset..]); - offset += user_reward_manager.size_in_bytes_when_packed(); + offset += user_reward_manager.get_packed_len(); } } else if dst.len() > OBLIGATION_LEN_V2_0_2 { // set the length to 0 if obligation was resized before @@ -795,7 +795,7 @@ impl Obligation { let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for _ in 0..*len { let user_reward_manager = UserRewardManager::unpack_from_slice(&src[offset..])?; - offset += user_reward_manager.size_in_bytes_when_packed(); + offset += user_reward_manager.get_packed_len(); user_reward_managers.push(user_reward_manager); } From 9c615e0db31a3841a51ca90722252f37bb9d26f2 Mon Sep 17 00:00:00 2001 From: vanity Date: Tue, 15 Apr 2025 19:07:43 +0200 Subject: [PATCH 14/19] Documenting liquidity mining --- token-lending/LIQUIDITY_MINING.md | 217 ++++++++++++++++++ .../liquidity_mining/cancel_pool_reward.rs | 2 +- .../liquidity_mining/pool_reward_manager.rs | 4 +- 3 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 token-lending/LIQUIDITY_MINING.md diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md new file mode 100644 index 00000000000..5fe3325abeb --- /dev/null +++ b/token-lending/LIQUIDITY_MINING.md @@ -0,0 +1,217 @@ +# Liquidity Mining + +## Overview + +The liquidity mining feature models the same feature implemented in [Suilend][suilend-lm]. +In a gist we track deposits and borrows for each reserve in two structures: pool reward manager that exist on each reserve and user reward manager that exist on linked obligations. + +Deposits increase total pool shares and user shares by the exact amount of collateral token deposited into an obligation. +Collateral token that is _not_ deposited into any obligation does not count toward the total pool shares. + +Conversely, withdraws decrease total pool shares and user shares by the exact amount of collateral token withdrawn from an obligation. + +Similarly, borrows increase total pool shares and user shares. +However, the amount of shares is determined by "liability shares". +Liability shares are calculated for an obligation as `(borrow_amount / cumulative_borrow_rate)`. + +Conversely, repays decrease total pool shares and user shares. + +When a user deposits, withdraws, borrows or repays, we calculate their effective shares and then set them rather than incrementing/decrementing them. + +An obligation can also be liquidated which is a process of repaying and withdrawing. +This adequately updates the pool reward manager and user reward manager deposit shares for the withdraw reserve and liability shares for the repay reserve. + +An obligation's debt can also be forgiven. +This is an act of repaying and the liability shares are updated accordingly. + +## Differences to Suilend + +In Suilend a reserve can have at most 50 rewards. +However, Sui dynamic object model let's us store more data easily. +In Save we're storing the data on the reserve and this means packing and +unpacking it frequently which negatively impacts CU limits. +We lower the number of rewards to 30. +In Save, if we want to add new rewards we will crank old ones to make space +in the reserve if there isn't any. + +In Suilend we store the amount of rewards that have been made available to users already. +We keep adding `(total_rewards * time_passed) / (total_time)` every time someone interacts with the manager. +This value is used to transfer the unallocated rewards to the admin. +However, this can be calculated dynamically which avoids storing an extra packed decimal (16 bytes) on each reserve's pool reward (30). + +## New ixs + +There's a common concept of reward vault and reward vault authority across the ixs. +A reward vault is a token account that stores reward tokens for a specific pool reward. +A reward vault authority is a PDA that is used to sign CPIs into the token program for the reward vault. + +```rust +// the seeds for the reward vault authority +[ + b"RewardVaultAuthority", + lending_market_key, + reserve_key, + reward_mint_key, +] +``` + +> TBD: Should we use the reward vault token account pubkey instead to create a 1-1 relationship between the authority and the vault? +> What will be easier for the clients to use? + +### `add_pool_reward` + +Admin only ix that adds a new pool reward to a reserve's reward manager, either a deposit or a borrow one. +This ix will fail if all slots are occupied. + +There's a minimum reward period of 1 hour, no short rewards are allowed. + +Each pool reward has a unique vault that holds the reward tokens. +This vault account must be created for the token program before calling this ix. +In this ix we initialize the account as token account and transfer the reward tokens to it from the admin's token account. + +### `cancel_pool_reward` + +This ix sets the end time of the pool reward to "now" and returns any unallocated rewards to the admin. +Users will still be able to claim rewards they accrued until this point. + +### `claim_pool_reward` + +Permission-less way to claim allocated user liquidity mining rewards. + +It finds the UserRewardManager for the reserve and obligation and withdraws +all eligible rewards from it. +The eligible rewards are then transferred to the obligation owners's token account. + +Anyone can call this ix which is useful for cranking. + +Alternatively, if the obligation is not yet migrated, this does the migration for the obligation as well. +See [Migrations](#migrations) section for more details. + +### `close_pool_reward` + +Closes a pool reward, making its slot vacant and ready for a new reward. + +Before closing a pool reward that pool reward must first be cancelled and all rewards must be claimed by the users. + +### `upgrade_reserve` + +Temporary ix to upgrade a reserve to LM feature added in @v2.0.2. +Fails if reserve was not sized as @v2.0.2 (ie. has been upgraded or created with @v2.1.00). + +Until this ix is called for a Reserve account, all other ixs that try to unpack the Reserve will fail due to size mismatch. + +## Changes + +This section is partly relevant also to client implementations. +There are breaking changes introduced with this version. + +### First byte of each account is discriminator + +In @v2.0.2 the first byte of any _initialized_ account was set to the program version, ie. `0x01`. +Once any account is mutably packed in @v2.1.0, the first byte will be set to the account discriminator: + +```rust +/// Match the first byte of an account data against this enum to determine +/// the account type. +/// +/// # Note +/// +/// In versions before @v2.1.0 this byte represented program version. +/// That's why we skip value `1u8`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AccountDiscriminator { + /// Account is not initialized yet. + #[default] + Uninitialized = 0, + /// [crate::state::LendingMarket] + LendingMarket = 2, + /// [crate::state::Reserve] + Reserve = 3, + /// [crate::state::Obligation] + Obligation = 4, +} +``` + +### Obligations need more rent + +Because obligations track user rewards that depend on the number of reserves and the rewards that those reserves have, we now dynamically reallocate size of obligation accounts. + +This means that sometimes obligations will need more rent than before and this rent must be (`system_program`) transferred to the obligation account before any interaction with the borrow-lending program. + +The calculation for an upper-bound of an obligation size from which rent-exempt balance is calculated: + +```math +\overline{s} = 1301 + \sum_{i=0}^{n} 50 + 37 * m_{i} +``` + +Where $`n`$ is the number of reserves in the obligation and $`m_{i}`$ is the number of pool rewards in obligation's reserve manager $`i`$. + +This is an upper-bound because some of those pool rewards might be over and therefore wouldn't be copied to the obligation. + +The particular obligation's reserve manager depends on whether the obligation is a borrow or deposit obligation. + +### Reserve size increased + +Migrated reserve accounts are sized at 5451 bytes. + +### CUs increased for all reserve/obligation related ixs + +We increase the reserve size and the obligation size which costs more compute when (un)packing. +Additionally, we now write to the reserve account on withdrawal to update the total shares. + +All this means more CUs are needed for ixs to succeed. +Additionally, the CUs increase linearly with the number of rewards in each involved reserve. + +> TBD: Let's review together the limits used in the present client implementation. + +### Reserve account in processor is protected by runtime borrow checker + +In @v2.0.2 access to reserve account followed a pattern of unpacking an immutable reference to a cloned memory location, working with it and then mutably packing it back to the original location. +This introduced extra up(pack)ing operations and was prone to double spend bugs. + +In this version we're leveraging the `solana_program` framework's usage of `Cell` container. +We keep a `Ref`/`RefMut` around in a wrapper struct along with the unpacked reserve struct and automatically pack it back to the original location when `RefMut` is dropped. +This way we guarantee at runtime that only one mutable reference to the reserve exists at any time. + +## Migrations + +### `Reserve` + +There's a CLI command for `UpgradeReserveToV2_1_0` ix to permission-lessly upgrade a reserve account. +Once upgraded any subsequent calls to this ix for the specific reserve will fail. +The upgrade requires 4832 extra bytes which amounts to ~0.035 $SOL. +Some reserves have extra rent and won't require the full amount. +The `UpgradeReserveToV2_1_0` ix can be delete as soon as all reserves are migrated. + +### `Obligation` + +To start tracking rewards for an obligation we need to set its shares to the appropriate amount. +They are at 0 before the obligation is fully migrated. + +We can call `claim_pool_reward` ix to do this, or any deposit/withdraw/repay/borrow ix. + +> Prior to version @2.1.0 there was no concept of liq. mining. +> That means user shares are going to be 0 even if they have a borrow or deposit. +> This ix can be used to start tracking obligation's rewards. + +The obligation will be reallocated if it needs more space to add extra rewards. +Client must ensure that the obligation has enough rent-exempt balance. +All obligations would benefit from a extra airdropped rent about `1 + 50 * obligation_reserves` lamports. + +### `LendingMarket` + +The lending market account is not changed in this version except for the first byte discriminator. + +A lending market will be automatically upgraded on the first mutable ix. + +## Outstanding work + +- [ ] Review feature parity with Suilend +- [ ] Consider changing the reward vault authority seed +- [ ] Consider having another admin account to manage the rewards +- [ ] Consider spending some rent to the obligations from the reclaimed merkle-tree reward distributor +- [ ] Discuss CU limits with the Save client team + + + +[suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs index 12677690448..29f1ab9ee7b 100644 --- a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs @@ -1,6 +1,6 @@ //! Cancel a pool reward. //! -//! This ix sets the end time of the pool reward to now are returns any +//! This ix sets the end time of the pool reward to now and returns any //! unallocated rewards to the admin. //! Users will still be able to claim rewards. diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs index b255b88a7e6..370287c687c 100644 --- a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -83,8 +83,8 @@ pub enum PoolRewardSlot { /// We keep adding `(total_rewards * time_passed) / (total_time)` every /// time someone interacts with the manager. /// This value is used to transfer the unallocated rewards to the admin. -/// However, this can be calculated dynamically which avoids storing extra -/// [Decimal] on each [PoolReward]. +/// However, this can be calculated dynamically which avoids storing an extra +/// packed [Decimal] on each [PoolReward]. #[derive(Clone, Debug, Default, PartialEq)] pub struct PoolReward { /// Unique ID for this slot that has never been used before, and will never From 9e071d805788e4103860f073b1648d661c9db584 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Fri, 2 May 2025 08:47:41 +0200 Subject: [PATCH 15/19] [Liquidity Mining] Edit pool reward (12) (#213) * Edit reward instead of cancel * Proptest for reward edit * Renaming cancel to edit * Adding BPF tests for extending reward period * Fixing impression in existing tests * Addressing clippy --- Cargo.lock | 1 + token-lending/LIQUIDITY_MINING.md | 22 +- token-lending/program/src/processor.rs | 8 +- .../program/src/processor/liquidity_mining.rs | 4 +- ...cel_pool_reward.rs => edit_pool_reward.rs} | 100 +++-- .../program/tests/add_pool_reward.rs | 4 +- .../program/tests/cancel_pool_reward.rs | 140 ------- .../program/tests/claim_pool_reward.rs | 6 +- .../program/tests/close_pool_reward.rs | 4 +- .../program/tests/edit_pool_reward.rs | 274 ++++++++++++++ .../tests/helpers/solend_program_test.rs | 10 +- token-lending/sdk/Cargo.toml | 1 + token-lending/sdk/src/error.rs | 2 +- token-lending/sdk/src/instruction.rs | 38 +- .../sdk/src/state/liquidity_mining.rs | 348 +++++++++++++++++- .../liquidity_mining/pool_reward_manager.rs | 205 +++++++---- .../liquidity_mining/user_reward_manager.rs | 6 +- 17 files changed, 878 insertions(+), 295 deletions(-) rename token-lending/program/src/processor/liquidity_mining/{cancel_pool_reward.rs => edit_pool_reward.rs} (60%) delete mode 100644 token-lending/program/tests/cancel_pool_reward.rs create mode 100644 token-lending/program/tests/edit_pool_reward.rs diff --git a/Cargo.lock b/Cargo.lock index 75fb11b8532..efd858b9cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5492,6 +5492,7 @@ dependencies = [ "pretty_assertions", "proptest", "rand 0.8.5", + "rand_chacha 0.3.1", "serde", "serde_yaml 0.8.26", "solana-program", diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md index 5fe3325abeb..cce28cfa425 100644 --- a/token-lending/LIQUIDITY_MINING.md +++ b/token-lending/LIQUIDITY_MINING.md @@ -69,11 +69,29 @@ Each pool reward has a unique vault that holds the reward tokens. This vault account must be created for the token program before calling this ix. In this ix we initialize the account as token account and transfer the reward tokens to it from the admin's token account. -### `cancel_pool_reward` +### `edit_pool_reward` -This ix sets the end time of the pool reward to "now" and returns any unallocated rewards to the admin. +Both extending and shortening calculate the difference between total rewards linearly. Users will still be able to claim rewards they accrued until this point. +#### Cancel + +Cancelling a pool reward can be done by setting the end time to 0. +Note that only rewards longer than [solend_sdk::MIN_REWARD_PERIOD_SECS] can be cancelled. +In this case we transfer tokens from the reward vault to the lending market reward token account. + +#### Shorten + +If the new endtime is in the future, larger than start time and smaller than previous end time +then we shorten the reward period, refunding the unallocated rewards to the lending market +reward token account. + +#### Extend + +If the new endtime is in the future, larger than start time and larger than previous end time +then we extend the reward period, taking more tokens from the lending market reward token +account. + ### `claim_pool_reward` Permission-less way to claim allocated user liquidity mining rewards. diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 71f1a115e60..0642a9cd2ed 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -225,17 +225,19 @@ pub fn process_instruction( accounts, ) } - LendingInstruction::CancelPoolReward { + LendingInstruction::EditPoolReward { reward_authority_bump, position_kind, pool_reward_index, + new_end_time_secs, } => { - msg!("Instruction: Cancel Pool Reward"); - liquidity_mining::cancel_pool_reward::process( + msg!("Instruction: Edit Pool Reward"); + liquidity_mining::edit_pool_reward::process( program_id, reward_authority_bump, position_kind, pool_reward_index as _, + new_end_time_secs, accounts, ) } diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 6f2bfb2a94c..1a906ca4306 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -8,7 +8,7 @@ //! //! There are three admin-only ixs: //! - [add_pool_reward] -//! - [cancel_pool_reward] +//! - [edit_pool_reward] //! - [close_pool_reward] //! //! There is an ix related to migration: @@ -20,9 +20,9 @@ //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 pub(crate) mod add_pool_reward; -pub(crate) mod cancel_pool_reward; pub(crate) mod claim_user_reward; pub(crate) mod close_pool_reward; +pub(crate) mod edit_pool_reward; pub(crate) mod upgrade_reserve; use solana_program::program_pack::Pack; diff --git a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs similarity index 60% rename from token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs rename to token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs index 29f1ab9ee7b..495616afc0f 100644 --- a/token-lending/program/src/processor/liquidity_mining/cancel_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs @@ -1,8 +1,23 @@ -//! Cancel a pool reward. +//! Edits a pool reward. //! -//! This ix sets the end time of the pool reward to now and returns any -//! unallocated rewards to the admin. -//! Users will still be able to claim rewards. +//! # Cancel +//! Cancelling a pool reward can be done by setting the end time to 0. +//! Note that only rewards longer than [solend_sdk::MIN_REWARD_PERIOD_SECS] can be cancelled. +//! In this case we transfer tokens from the reward vault to the lending market reward token account. +//! +//! # Shorten +//! If the new endtime is in the future, larger than start time and smaller than previous end time +//! then we shorten the reward period, refunding the unallocated rewards to the lending market +//! reward token account. +//! +//! # Extend +//! If the new endtime is in the future, larger than start time and larger than previous end time +//! then we extend the reward period, taking more tokens from the lending market reward token +//! account. +//! +//! --- +//! +//! Both extending and shortening calculate the difference between total rewards linearly. use crate::processor::liquidity_mining::{ check_and_unpack_pool_reward_accounts_for_admin_ixs, unpack_token_account, @@ -23,7 +38,7 @@ use solend_sdk::{error::LendingError, state::PositionKind}; use super::{Bumps, CheckAndUnpackPoolRewardAccounts, ReserveBorrow}; /// Use [Self::from_unchecked_iter] to validate the accounts. -struct CancelPoolRewardAccounts<'a, 'info> { +struct EditPoolRewardAccounts<'a, 'info> { /// ✅ belongs to this program /// ✅ unpacks /// ✅ belongs to `lending_market_info` @@ -34,7 +49,7 @@ struct CancelPoolRewardAccounts<'a, 'info> { /// ✅ belongs to the token program /// ✅ matches `reward_mint_info` /// ✅ is writable - reward_token_destination_info: &'a AccountInfo<'info>, + lending_market_reward_token_account_info: &'a AccountInfo<'info>, /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` reward_authority_info: &'a AccountInfo<'info>, /// ❓ we don't know whether it matches the reward vault pubkey stored in [Reserve] @@ -45,7 +60,7 @@ struct CancelPoolRewardAccounts<'a, 'info> { lending_market_info: &'a AccountInfo<'info>, /// ✅ is a signer /// ✅ matches `lending_market_info` - _lending_market_owner_info: &'a AccountInfo<'info>, + lending_market_owner_info: &'a AccountInfo<'info>, /// ✅ matches `lending_market_info` token_program_info: &'a AccountInfo<'info>, @@ -54,16 +69,18 @@ struct CancelPoolRewardAccounts<'a, 'info> { /// # Effects /// -/// 1. Cancels any further reward emission, effectively setting end time to now. -/// 2. Transfers any unallocated rewards to the `reward_token_destination` account. +/// 1. Sets the new time +/// 2. Either refunds the admin or takes more tokens from the admin, based on the new end time +/// relation to the old end time pub(crate) fn process( program_id: &Pubkey, reward_authority_bump: u8, position_kind: PositionKind, pool_reward_index: usize, + new_end_time_secs: u64, accounts: &[AccountInfo], ) -> ProgramResult { - let mut accounts = CancelPoolRewardAccounts::from_unchecked_iter( + let mut accounts = EditPoolRewardAccounts::from_unchecked_iter( program_id, Bumps { reward_authority: reward_authority_bump, @@ -73,10 +90,10 @@ pub(crate) fn process( // 1. - let (expected_vault, unallocated_rewards) = accounts + let (expected_vault, change_to_vault_amount) = accounts .reserve .pool_reward_manager_mut(position_kind) - .cancel_pool_reward(pool_reward_index, &Clock::get()?)?; + .edit_pool_reward(pool_reward_index, new_end_time_secs, &Clock::get()?)?; if expected_vault != *accounts.reward_token_vault_info.key { msg!("Reward vault provided does not match the reward vault pubkey stored in [Reserve]"); @@ -85,33 +102,46 @@ pub(crate) fn process( // 2. - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_vault_info.clone(), - destination: accounts.reward_token_destination_info.clone(), - amount: unallocated_rewards, - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &[ - reward_vault_authority_seeds( - accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, - ) - .as_slice(), - &[&[reward_authority_bump]], - ] - .concat(), - token_program: accounts.token_program_info.clone(), - })?; - - Ok(()) + msg!("Change to vault amount: {}", change_to_vault_amount); + + match change_to_vault_amount { + 0 => Ok(()), + // transfer more tokens to the vault + 1.. => spl_token_transfer(TokenTransferParams { + source: accounts.lending_market_reward_token_account_info.clone(), + destination: accounts.reward_token_vault_info.clone(), + amount: change_to_vault_amount.unsigned_abs(), + authority: accounts.lending_market_owner_info.clone(), + authority_signer_seeds: &[], + token_program: accounts.token_program_info.clone(), + }), + // refund to lending market reward token account + ..=-1 => spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.lending_market_reward_token_account_info.clone(), + amount: change_to_vault_amount.unsigned_abs(), + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + &accounts.reserve.key(), + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), + token_program: accounts.token_program_info.clone(), + }), + } } -impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { +impl<'a, 'info> EditPoolRewardAccounts<'a, 'info> { fn from_unchecked_iter( program_id: &Pubkey, bump: Bumps, iter: &mut impl Iterator>, - ) -> Result, ProgramError> { + ) -> Result, ProgramError> { let reserve_info = next_account_info(iter)?; let reward_mint_info = next_account_info(iter)?; let reward_token_destination_info = next_account_info(iter)?; @@ -163,11 +193,11 @@ impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { Ok(Self { _reserve_info: reserve_info, reward_mint_info, - reward_token_destination_info, + lending_market_reward_token_account_info: reward_token_destination_info, reward_authority_info, reward_token_vault_info, lending_market_info, - _lending_market_owner_info: lending_market_owner_info, + lending_market_owner_info, token_program_info, reserve, diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs index 09407066fb4..9392eb785b8 100644 --- a/token-lending/program/tests/add_pool_reward.rs +++ b/token-lending/program/tests/add_pool_reward.rs @@ -12,7 +12,7 @@ use solend_program::{ math::Decimal, state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, }; -use solend_sdk::state::{PoolReward, PoolRewardSlot, UserReward}; +use solend_sdk::state::{PoolReward, PoolRewardEntry, UserReward}; #[tokio::test] async fn test_add_pool_reward_for_deposit() { @@ -64,7 +64,7 @@ async fn test_(position_kind: PositionKind) { pool_rewards: { let mut og = PoolRewardManager::default().pool_rewards; - og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { id: PoolRewardId(1), vault: reward_vault.pubkey(), start_time_secs: current_time, diff --git a/token-lending/program/tests/cancel_pool_reward.rs b/token-lending/program/tests/cancel_pool_reward.rs deleted file mode 100644 index fa09068aca4..00000000000 --- a/token-lending/program/tests/cancel_pool_reward.rs +++ /dev/null @@ -1,140 +0,0 @@ -#![cfg(feature = "test-bpf")] - -mod helpers; - -use std::collections::HashSet; - -use helpers::solend_program_test::{ - setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, -}; -use helpers::test_reserve_config; - -use pretty_assertions::assert_eq; -use solana_program_test::*; -use solana_sdk::signature::{Keypair, Signer}; -use solend_program::{ - math::Decimal, - state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}, -}; -use solend_sdk::state::{PoolReward, PoolRewardSlot}; - -#[tokio::test] -async fn test_cancel_pool_reward_for_deposit() { - test_(PositionKind::Deposit).await; -} - -#[tokio::test] -async fn test_cancel_pool_reward_for_borrow() { - test_(PositionKind::Borrow).await; -} - -async fn test_(position_kind: PositionKind) { - let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = - setup_world(&test_reserve_config(), &test_reserve_config()).await; - - let reward_mint = test.create_mint_as_test_authority().await; - let reward_vault = Keypair::new(); - let duration_secs = 3_600; - let total_rewards = 1_000_000; - let initial_time = test.get_clock().await.unix_timestamp as u64; - let reward = LiqMiningReward { - mint: reward_mint, - vault: reward_vault.insecure_clone(), - }; - - lending_market - .add_pool_reward( - &mut test, - &usdc_reserve, - &mut lending_market_owner, - &reward, - position_kind, - initial_time, - initial_time + duration_secs as u64, - total_rewards, - ) - .await - .expect("Should add pool reward"); - - let balance_checker = BalanceChecker::start( - &mut test, - &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], - ) - .await; - - let current_time = test - .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) - .await; - - let pool_reward_index = 0; - lending_market - .cancel_pool_reward( - &mut test, - &usdc_reserve, - &mut lending_market_owner, - &reward, - position_kind, - pool_reward_index, - ) - .await - .expect("Should cancel pool reward"); - - let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; - - let expected_balance_changes = HashSet::from([ - TokenBalanceChange { - token_account: reward.vault.pubkey(), - mint: reward.mint, - diff: -(total_rewards as i128) / 2, - }, - TokenBalanceChange { - token_account: lending_market_owner.get_account(&reward.mint).unwrap(), - mint: reward.mint, - diff: (total_rewards as i128) / 2, - }, - ]); - assert_eq!(balance_changes, expected_balance_changes); - - let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; - - let expected_reward_manager = Box::new(PoolRewardManager { - total_shares: 0, - last_update_time_secs: current_time, - pool_rewards: { - let mut og = PoolRewardManager::default().pool_rewards; - - og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { - id: PoolRewardId(1), - vault: reward_vault.pubkey(), - start_time_secs: initial_time, - duration_secs: duration_secs / 2, - total_rewards, - num_user_reward_managers: 0, - cumulative_rewards_per_share: Decimal::zero(), - })); - - og - }, - }); - - match position_kind { - PositionKind::Deposit => { - assert_eq!( - usdc_reserve_post.account, - Reserve { - deposits_pool_reward_manager: expected_reward_manager, - ..usdc_reserve.clone().account - } - ); - } - PositionKind::Borrow => { - assert_eq!( - usdc_reserve_post.account, - Reserve { - borrows_pool_reward_manager: expected_reward_manager, - ..usdc_reserve.clone().account - } - ); - } - } -} diff --git a/token-lending/program/tests/claim_pool_reward.rs b/token-lending/program/tests/claim_pool_reward.rs index f48322796df..34acd62cd87 100644 --- a/token-lending/program/tests/claim_pool_reward.rs +++ b/token-lending/program/tests/claim_pool_reward.rs @@ -21,7 +21,7 @@ use solend_program::{ }; use solend_sdk::error::LendingError; use solend_sdk::math::TryMul; -use solend_sdk::state::{Obligation, PoolReward, PoolRewardSlot, UserReward}; +use solend_sdk::state::{Obligation, PoolReward, PoolRewardEntry, UserReward}; #[tokio::test] async fn test_claim_pool_reward_for_deposit() { @@ -167,7 +167,7 @@ async fn test_(position_kind: PositionKind) { pool_rewards: { let mut og = PoolRewardManager::default().pool_rewards; - og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { id: PoolRewardId(1), vault: reward_vault.pubkey(), start_time_secs: initial_time, @@ -248,7 +248,7 @@ async fn test_(position_kind: PositionKind) { assert_eq!( pool_reward_manager.pool_rewards[0], - PoolRewardSlot::Occupied(Box::new(PoolReward { + PoolRewardEntry::Occupied(Box::new(PoolReward { id: PoolRewardId(1), vault: reward_vault.pubkey(), start_time_secs: initial_time, diff --git a/token-lending/program/tests/close_pool_reward.rs b/token-lending/program/tests/close_pool_reward.rs index 1493d8a8e8f..9003a4e2c77 100644 --- a/token-lending/program/tests/close_pool_reward.rs +++ b/token-lending/program/tests/close_pool_reward.rs @@ -13,7 +13,7 @@ use pretty_assertions::assert_eq; use solana_program_test::*; use solana_sdk::signature::Keypair; use solend_program::state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}; -use solend_sdk::state::PoolRewardSlot; +use solend_sdk::state::PoolRewardEntry; #[tokio::test] async fn test_close_pool_reward_for_deposit() { @@ -88,7 +88,7 @@ async fn test_(position_kind: PositionKind) { pool_rewards: { let mut og = PoolRewardManager::default().pool_rewards; - og[0] = PoolRewardSlot::Vacant { + og[0] = PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(1), has_been_just_vacated: false, }; diff --git a/token-lending/program/tests/edit_pool_reward.rs b/token-lending/program/tests/edit_pool_reward.rs new file mode 100644 index 00000000000..91b26d76251 --- /dev/null +++ b/token-lending/program/tests/edit_pool_reward.rs @@ -0,0 +1,274 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::signature::{Keypair, Signer}; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve}, +}; +use solend_sdk::state::{PoolReward, PoolRewardEntry}; + +#[tokio::test] +async fn test_cancel_pool_reward_for_deposit() { + test_cancel_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_cancel_pool_reward_for_borrow() { + test_cancel_(PositionKind::Borrow).await; +} + +#[tokio::test] +async fn test_extend_pool_reward_for_deposit() { + test_extend_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_extend_pool_reward_for_borrow() { + test_extend_(PositionKind::Borrow).await; +} + +async fn test_cancel_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 10 * 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let balance_checker = BalanceChecker::start( + &mut test, + &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], + ) + .await; + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + let pool_reward_index = 0; + lending_market + .edit_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + 0, // cancel + ) + .await + .expect("Should cancel pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let diff = (total_rewards as i128) / 2 - 1; + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -diff, + }, + TokenBalanceChange { + token_account: lending_market_owner.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs: duration_secs / 2, + total_rewards: total_rewards - diff as u64, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} + +async fn test_extend_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 10 * 3_600u32; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let lending_market_owner_reward_token_account = + lending_market_owner.get_account(&reward.mint).unwrap(); + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + test.mint_to( + &reward.mint, + &lending_market_owner_reward_token_account, + total_rewards, + ) + .await; + + let balance_checker = BalanceChecker::start( + &mut test, + &[&TokenAccount(reward.vault.pubkey()), &lending_market_owner], + ) + .await; + + let pool_reward_index = 0; + lending_market + .edit_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + pool_reward_index, + initial_time + duration_secs as u64 * 2, // twice as long + ) + .await + .expect("Should extend pool reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: total_rewards as i128, + }, + TokenBalanceChange { + token_account: lending_market_owner_reward_token_account, + mint: reward.mint, + diff: -(total_rewards as i128), + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let expected_reward_manager = Box::new(PoolRewardManager { + total_shares: 0, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardEntry::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs: duration_secs * 2, + total_rewards: total_rewards * 2, + num_user_reward_managers: 0, + cumulative_rewards_per_share: Decimal::zero(), + })); + + og + }, + }); + + match position_kind { + PositionKind::Deposit => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + deposits_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + PositionKind::Borrow => { + assert_eq!( + usdc_reserve_post.account, + Reserve { + borrows_pool_reward_manager: expected_reward_manager, + ..usdc_reserve.clone().account + } + ); + } + } +} diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 1f45cabdbbf..61f2c9528d3 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -75,7 +75,7 @@ mod cu_budgets { pub(super) const DEPOSIT_RESERVE_LIQUIDITY_AND_OBLIGATION_COLLATERAL: u32 = 130_015; pub(super) const REDEEM: u32 = 90_016; pub(super) const ADD_POOL_REWARD: u32 = 80_017; - pub(super) const CANCEL_POOL_REWARD: u32 = 80_018; + pub(super) const EDIT_POOL_REWARD: u32 = 80_018; pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; pub(super) const CLAIM_POOL_REWARD: u32 = 80_020; } @@ -985,7 +985,7 @@ impl Info { .await } - pub async fn cancel_pool_reward( + pub async fn edit_pool_reward( &self, test: &mut SolendProgramTest, reserve: &Info, @@ -993,6 +993,7 @@ impl Info { reward: &LiqMiningReward, position_kind: PositionKind, pool_reward_index: u64, + new_end_time_secs: u64, ) -> Result<(), BanksClientError> { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), @@ -1002,12 +1003,13 @@ impl Info { ); let instructions = [ - ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CANCEL_POOL_REWARD), - cancel_pool_reward( + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::EDIT_POOL_REWARD), + edit_pool_reward( solend_program::id(), reward_authority_bump, position_kind, pool_reward_index, + new_end_time_secs, reserve.pubkey, reward.mint, lending_market_owner.get_account(&reward.mint).unwrap(), diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index 0cc1bc180af..26ddb0536e7 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -26,6 +26,7 @@ log = "0.4.14" pretty_assertions = "1.4.1" proptest = "1.6" rand = "0.8.5" +rand_chacha = "0.3.1" serde = ">=1.0.140" serde_yaml = "0.8" solana-sdk = ">=1.9" diff --git a/token-lending/sdk/src/error.rs b/token-lending/sdk/src/error.rs index 1bca3f2c3fb..fb2a6fa6761 100644 --- a/token-lending/sdk/src/error.rs +++ b/token-lending/sdk/src/error.rs @@ -228,7 +228,7 @@ pub enum LendingError { NoPoolRewardMatches, /// There's no vacant slot for a pool reward #[error("There's no vacant slot for a pool reward")] - NoVacantSlotForPoolReward, + NoVacantEntryForPoolReward, } impl From for ProgramError { diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index e7e4a251b68..11adbf39cca 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -591,11 +591,14 @@ pub enum LendingInstruction { }, // 27 - /// CancelPoolReward + /// EditPoolReward /// /// * Admin only instruction. - /// * Changed the endtime of the reward to the current time. - /// * Claims unallocated rewards to the admin signer. + /// * Either extends or shortens the reward time. + /// * Provide `now` or less to changed the endtime of the reward to the current time, + /// effectively cancelling the reward. + /// * Claims unallocated rewards to the admin signer if shortening the time, takes extra rewards + /// from the admin signer if extending the time. /// /// `[writable]` Reserve account. /// `[]` Reward mint. @@ -609,13 +612,17 @@ pub enum LendingInstruction { /// `[]` Lending market account. /// `[signer]` Lending market owner. /// `[]` Token program. - CancelPoolReward { + EditPoolReward { /// The bump seed of the reward authority. reward_authority_bump: u8, /// Whether this reward applies to deposits or borrows position_kind: PositionKind, /// Identifies a reward within a reserve's deposits/borrows rewards. pool_reward_index: u64, + /// Will be truncated such that the duration in secs is not longer than [u32::MAX] and not + /// shorter than [crate::MIN_REWARD_PERIOD_SECS]. + /// Also, it must be at least current time and the reward start time. + new_end_time_secs: u64, }, /// 28 @@ -940,11 +947,14 @@ impl LendingInstruction { 27 => { let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; let (position_kind, rest) = Self::unpack_try_from_u8(rest)?; - let (pool_reward_index, _rest) = Self::unpack_u64(rest)?; - Self::CancelPoolReward { + let (pool_reward_index, rest) = Self::unpack_u64(rest)?; + let (new_end_time_secs, _rest) = Self::unpack_u64(rest)?; + + Self::EditPoolReward { reward_authority_bump, position_kind, pool_reward_index: pool_reward_index as _, + new_end_time_secs, } } 28 => { @@ -1288,15 +1298,17 @@ impl LendingInstruction { buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); } - Self::CancelPoolReward { + Self::EditPoolReward { reward_authority_bump, position_kind, pool_reward_index, + new_end_time_secs, } => { buf.push(27); buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); buf.extend_from_slice(&pool_reward_index.to_le_bytes()); + buf.extend_from_slice(&new_end_time_secs.to_le_bytes()); } Self::ClaimReward { reward_authority_bump, @@ -2179,16 +2191,17 @@ pub fn add_pool_reward( } } -/// Creates a `CancelPoolReward` instruction +/// Creates an `EditPoolReward` instruction #[allow(clippy::too_many_arguments)] -pub fn cancel_pool_reward( +pub fn edit_pool_reward( program_id: Pubkey, reward_authority_bump: u8, position_kind: PositionKind, pool_reward_index: u64, + new_end_time_secs: u64, reserve: Pubkey, reward_mint: Pubkey, - destination_reward_token_account: Pubkey, + lending_market_owner_reward_token_account: Pubkey, reward_vault_authority: Pubkey, reward_vault: Pubkey, lending_market: Pubkey, @@ -2199,17 +2212,18 @@ pub fn cancel_pool_reward( accounts: vec![ AccountMeta::new(reserve, false), AccountMeta::new_readonly(reward_mint, false), - AccountMeta::new(destination_reward_token_account, false), + AccountMeta::new(lending_market_owner_reward_token_account, false), AccountMeta::new_readonly(reward_vault_authority, false), AccountMeta::new(reward_vault, false), AccountMeta::new_readonly(lending_market, false), AccountMeta::new_readonly(lending_market_owner, true), AccountMeta::new_readonly(spl_token::id(), false), ], - data: LendingInstruction::CancelPoolReward { + data: LendingInstruction::EditPoolReward { reward_authority_bump, position_kind, pool_reward_index, + new_end_time_secs, } .pack(), } diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 16886e522b7..19b97d454f1 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -18,7 +18,337 @@ pub use user_reward_manager::*; pub const MAX_REWARDS: usize = 30; /// Cannot create a reward shorter than this. -pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; +pub const MIN_REWARD_PERIOD_SECS: u32 = 3_600; + +#[cfg(test)] +mod tests { + use super::*; + use crate::math::{TryDiv, TryMul}; + use crate::{ + error::LendingError, + math::Decimal, + state::{PoolRewardManager, PositionKind, UserRewardManager}, + }; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + use rand::prelude::*; + use rand_chacha::ChaCha8Rng; + use solana_program::{clock::Clock, msg, program_error::ProgramError, pubkey::Pubkey}; + use std::convert::TryFrom; + + /// This test asserts that cancelling a reward does not change the amount of rewards that are + /// emitted to a user. + #[test] + fn it_cancels_reward_without_changing_user_eligible_amount() { + // This is an implementation that was superseded by the edit function. + // We show that cancelling is a special case of editing. + impl PoolRewardManager { + fn cancel_pool_reward( + &mut self, + pool_reward_index: usize, + clock: &Clock, + ) -> Result<(Pubkey, u64), ProgramError> { + self.update(clock)?; + + let Some(PoolRewardEntry::Occupied(pool_reward)) = + self.pool_rewards.get_mut(pool_reward_index) + else { + msg!("Cannot cancel a non-existent pool reward"); + return Err(ProgramError::InvalidArgument); + }; + + if pool_reward.has_ended(clock) { + msg!("Cannot cancel a pool reward that has already ended"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; + let unlocked_rewards = Decimal::from(pool_reward.total_rewards) + .try_mul(Decimal::from(since_start_secs))? + .try_div(Decimal::from(pool_reward.duration_secs as u64))? + .try_floor_u64()?; + let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; + + pool_reward.duration_secs = + u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); + + Ok((pool_reward.vault, remaining_rewards)) + } + } + + let usdc = Pubkey::new_unique(); // reserve pubkey + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + let reward_period = 10 * MIN_REWARD_PERIOD_SECS as u64; + let total_rewards = 100 * 1_000_000; + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + let mut user_reward_manager = UserRewardManager::new(usdc, PositionKind::Deposit, &clock); + user_reward_manager + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + user_reward_manager.set_share(&mut pool_reward_manager, 1); + + pool_reward_manager + .add_pool_reward(slnd_vault, 0, reward_period, total_rewards, &clock) + .expect("It adds pool reward"); + + clock.unix_timestamp = reward_period as i64 / 2; + + let not_cancelled_claimed_slnd = { + let mut pool_reward_manager = pool_reward_manager.clone(); + let mut user_reward_manager = user_reward_manager.clone(); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + let edited_claimed_slnd = { + let mut pool_reward_manager = pool_reward_manager.clone(); + let mut user_reward_manager = user_reward_manager.clone(); + + let pool_reward_index = 0; + let end_now = 0; // should be same as cancel really + pool_reward_manager + .edit_pool_reward(pool_reward_index, end_now, &clock) + .expect("It edits pool reward"); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + let canceled_claimed_slnd = { + let pool_reward_index = 0; + pool_reward_manager + .cancel_pool_reward(pool_reward_index, &clock) + .expect("It cancels pool reward"); + + user_reward_manager + .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) + .expect("It claims rewards") + }; + + assert_eq!(not_cancelled_claimed_slnd, canceled_claimed_slnd); + assert_eq!(edited_claimed_slnd, canceled_claimed_slnd); + } + + #[test] + fn it_extends_reward() { + let slnd_vault = Pubkey::new_unique(); // where rewards are stored + let reward_period = 10 * MIN_REWARD_PERIOD_SECS as u64; + let total_rewards = 100 * 1_000_000; + + let clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut pool_reward_manager = PoolRewardManager::default(); + + pool_reward_manager + .add_pool_reward(slnd_vault, 0, reward_period, total_rewards, &clock) + .expect("It adds pool reward"); + + let pool_reward_index = 0; + let (vault, amount) = pool_reward_manager + .edit_pool_reward(pool_reward_index, reward_period * 2, &clock) + .expect("It edits pool reward"); + + assert!(amount.is_positive()); + assert_eq!(amount as u64, total_rewards); + assert_eq!(vault, slnd_vault); + + let PoolRewardEntry::Occupied(pool_reward) = + &pool_reward_manager.pool_rewards[pool_reward_index] + else { + panic!("Expected pool reward to be occupied"); + }; + + assert_eq!(pool_reward.total_rewards, total_rewards * 2); + assert_eq!(pool_reward.duration_secs, reward_period as u32 * 2); + } + + proptest! { + #[test] + fn it_yields_expected_rewards_if_edited( + rng_seed in 0..u64::MAX, + user_count in 1..10usize, + reward_period in MIN_REWARD_PERIOD_SECS..1000*MIN_REWARD_PERIOD_SECS, + total_rewards in 1_000_000..10_000_000_000_000u64, + ) { + let mut rng = ChaCha8Rng::seed_from_u64(rng_seed); + + let usdc = Pubkey::new_unique(); + let position_kind = PositionKind::Deposit; + let foo_vault = Pubkey::new_unique(); // this one is not edited + let bar_vault = Pubkey::new_unique(); // we'll edit this one + + let edit_reward_after_timestamp = rng.gen_range( + 0..reward_period as i64 / 2, + ); + let edit_bar_to_end_at_timestamp = rng.gen_range( + 0..(reward_period * 2) as i64, + ); + + let mut clock = Clock { + unix_timestamp: 0, + ..Default::default() + }; + + let mut total_claimed_foo = 0; + let mut total_claimed_bar = 0; + + let mut pool_reward_manager = PoolRewardManager::default(); + + // both rewards start identically + + pool_reward_manager + .add_pool_reward(foo_vault, 0, reward_period as _, total_rewards, &clock) + .expect("It adds pool reward"); + + pool_reward_manager + .add_pool_reward(bar_vault, 0, reward_period as _, total_rewards, &clock) + .expect("It adds pool reward"); + + // all users start tracking the rewards with their respective shares + + let mut user_reward_managers: Vec<_> = (0..user_count) + .map(|_| { + let mut user_reward_manager = UserRewardManager::new(usdc, position_kind, &clock); + + user_reward_manager + .populate(&mut pool_reward_manager, &clock) + .expect("It populates user reward manager"); + + let user_share = rng.gen_range(0..1_000); + user_reward_manager.set_share(&mut pool_reward_manager, user_share); + user_reward_manager + }) + .collect(); + + while clock.unix_timestamp < edit_reward_after_timestamp { + clock.unix_timestamp += rng.gen_range(0..MIN_REWARD_PERIOD_SECS) as i64; + + for user_reward_manager in &mut user_reward_managers { + let claimed_foo = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + + let claimed_bar = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + + prop_assert_eq!(claimed_foo, claimed_bar); + + total_claimed_foo += claimed_foo; + total_claimed_bar += claimed_bar; + } + } + + // edit the second reward + + let bar_reward_index = 1; + let (_, change_in_bar_reward) = pool_reward_manager + .edit_pool_reward(bar_reward_index, edit_bar_to_end_at_timestamp as _, &clock) + .expect("It edits bar pool reward"); + + // now keep claiming until both rewards end + + loop { + clock.unix_timestamp += rng.gen_range(0..MIN_REWARD_PERIOD_SECS) as i64; + + let has_foo_ended = match &pool_reward_manager.pool_rewards[0] { + PoolRewardEntry::Occupied(pool_reward) => pool_reward.has_ended(&clock), + _ => unreachable!(), + }; + + let has_bar_ended = match &pool_reward_manager.pool_rewards[1] { + PoolRewardEntry::Occupied(pool_reward) => pool_reward.has_ended(&clock), + _ => unreachable!(), + }; + + let neither_has_ended = !has_foo_ended && !has_bar_ended; + + for user_reward_manager in &mut user_reward_managers { + let claimed_foo = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + + let claimed_bar = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + + total_claimed_foo += claimed_foo; + total_claimed_bar += claimed_bar; + + if neither_has_ended && claimed_foo != claimed_bar { + // due to rounding errors we can be a little off + let allowed_diff = 1; + let allowed_min = claimed_foo.saturating_sub(allowed_diff); + let allowed_range = allowed_min..=(claimed_foo + allowed_diff); + prop_assert!( + allowed_range.contains(&claimed_bar), + "Expected foo and bar rewards to equal, but got {} and {}", + claimed_foo, + claimed_bar + ); + } + } + + if has_foo_ended && has_bar_ended { + break; + } + } + + // check that no more rewards can be claimed + + for user_reward_manager in &mut user_reward_managers { + let claimed_foo = user_reward_manager + .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) + .expect("It claims foo rewards"); + prop_assert_eq!(claimed_foo, 0); + + let claimed_bar = user_reward_manager + .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) + .expect("It claims bar rewards"); + prop_assert_eq!(claimed_bar, 0); + } + + // check that the end state is what we'd expect + + // User's claimed no more than total_rewards and not much less either. + // Due to rounding issues we're ok with distributing one less token per user. + let max_allowed_diff = user_count as u64; + + let foo_allowed_range = (total_rewards - max_allowed_diff)..=total_rewards; + prop_assert!( + foo_allowed_range.contains(&total_claimed_foo), + "Foo claimed rewards {} not close to total rewards of {}..={}", + total_claimed_foo, + total_rewards - max_allowed_diff, + total_rewards + ); + + let expected_bar_total_rewards = (total_rewards as i64 + change_in_bar_reward) as u64; + let bar_allowed_range = + (expected_bar_total_rewards - max_allowed_diff)..=expected_bar_total_rewards; + prop_assert!( + bar_allowed_range.contains(&total_claimed_bar), + "Bar claimed rewards {} not close to total rewards of {}..={}", + total_claimed_bar, + expected_bar_total_rewards - max_allowed_diff, + expected_bar_total_rewards + ); + } + } +} #[cfg(test)] mod suilend_tests { @@ -28,7 +358,7 @@ mod suilend_tests { use crate::{ math::Decimal, state::{ - PoolReward, PoolRewardId, PoolRewardManager, PoolRewardSlot, PositionKind, + PoolReward, PoolRewardEntry, PoolRewardId, PoolRewardManager, PositionKind, UserRewardManager, MAX_REWARDS, }, }; @@ -64,7 +394,7 @@ mod suilend_tests { .expect("It adds pool reward"); assert_eq!( pool_reward_manager.pool_rewards[0], - PoolRewardSlot::Occupied(Box::new(PoolReward { + PoolRewardEntry::Occupied(Box::new(PoolReward { id: PoolRewardId(1), vault: slnd_vault, start_time_secs: 0, @@ -390,11 +720,13 @@ mod suilend_tests { { clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + let pool_reward_index = 0; + let new_end_time_secs = 0; // now let (from_vault, unallocated_rewards) = pool_reward_manager - .cancel_pool_reward(0, &clock) + .edit_pool_reward(pool_reward_index, new_end_time_secs, &clock) .expect("It cancels pool reward"); assert_eq!(from_vault, slnd_vault); - assert_eq!(unallocated_rewards, 50 * 1_000_000); + assert_eq!(unallocated_rewards, -50 * 1_000_000 + 1); // approx } { @@ -456,11 +788,13 @@ mod suilend_tests { { clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; + let pool_reward_index = 0; + let new_end_time_secs = 0; // now let (from_vault, unallocated_rewards) = pool_reward_manager - .cancel_pool_reward(0, &clock) + .edit_pool_reward(pool_reward_index, new_end_time_secs, &clock) .expect("It cancels pool reward"); assert_eq!(from_vault, slnd_vault1); - assert_eq!(unallocated_rewards, 50 * 1_000_000); + assert_eq!(unallocated_rewards, -50 * 1_000_000 + 1); // approx clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; let claim_slnd = user_reward_manager_1 diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs index 370287c687c..e457c32bbb9 100644 --- a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -1,9 +1,9 @@ //! [PoolRewardManager]s are stored in [crate::state::Reserve]s. -//! They can be either borrow or deposit but the logic is the same, the only -//! difference is how shares are calculated. +//! They can be either borrow or deposit but the logic is almost the same. //! -//! For borrow managers the shares are "liability" and for deposit -//! managers the shares are "deposited collateral". +//! The only difference is how shares are calculated: +//! For borrow managers the shares are "liability" and for deposit managers the shares are +//! "deposited collateral". use crate::{ error::LendingError, @@ -11,7 +11,7 @@ use crate::{ state::{pack_decimal, unpack_decimal, MAX_REWARDS, MIN_REWARD_PERIOD_SECS}, }; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; -use core::convert::{TryFrom, TryInto}; +use core::convert::TryInto; use solana_program::{ clock::Clock, msg, @@ -19,6 +19,7 @@ use solana_program::{ program_pack::{Pack, Sealed}, pubkey::{Pubkey, PUBKEY_BYTES}, }; +use std::cmp::Ordering; /// Each reserve has two managers: /// - one for deposits @@ -29,43 +30,43 @@ pub struct PoolRewardManager { pub total_shares: u64, /// Monotonically increasing time taken from clock sysvar. pub last_update_time_secs: u64, - /// New [PoolReward] are added to the first vacant slot. - pub pool_rewards: [PoolRewardSlot; MAX_REWARDS], + /// New [PoolReward] are added to the first vacant entry. + pub pool_rewards: [PoolRewardEntry; MAX_REWARDS], } -/// Each pool reward gets an ID which is monotonically increasing with each -/// new reward added to the pool at the particular slot. +/// Each pool reward gets an ID which is monotonically increasing with each new reward added to the +/// pool at the particular entry. /// -/// This helps us distinguish between two distinct rewards in the same array -/// index across time. +/// This helps us distinguish between two distinct rewards in the same array index across time. /// /// # Wrapping /// There are two strategies to handle wrapping: -/// 1. Consider the associated slot locked forever +/// 1. Consider the associated entry locked forever /// 2. Go back to 0. /// -/// Given that one reward lasts at [MIN_REWARD_PERIOD_SECS] we've got at least -/// half a million years before we need to worry about wrapping in a single slot. +/// Given that one reward lasts at least [MIN_REWARD_PERIOD_SECS] we've got at least half a million +/// years before we need to worry about wrapping in a single entry. /// I'd call that someone else's problem. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolRewardId(pub u32); /// # (Un)Packing -/// This is unpacked representation. -/// When packing we use the [PoolReward] `reward_mint` to determine whether the -/// reward is vacant or not to save space. +/// This is the unpacked representation. +/// When packing we use the [PoolReward] `reward_mint` to determine whether the reward is vacant or +/// not to save space. /// -/// If the pubkey is eq to default pubkey then slot is vacant. +/// If the pubkey is eq to default pubkey then entry is vacant. +/// We always pack the ID of the reward because it's monotonically increasing. +/// See [PoolRewardId] for more details. #[derive(Clone, Debug, PartialEq)] -pub enum PoolRewardSlot { - /// New reward can be added to this slot. +pub enum PoolRewardEntry { + /// New reward can be added to this entry. Vacant { /// Increment this ID when adding new [PoolReward]. last_pool_reward_id: PoolRewardId, /// An optimization to avoid writing data that has not changed. - /// When vacating a slot we set this to true. - /// That way the packing logic knows whether it's fine to skip the - /// packing or not. + /// When vacating a entry we set this to true. + /// That way the packing logic knows whether it's fine to skip the packing or not. has_been_just_vacated: bool, }, /// Reward has not been closed yet. @@ -78,32 +79,33 @@ pub enum PoolRewardSlot { /// /// # Reward cancellation /// -/// In Suilend we also store the amount of rewards that have been made available -/// to users already. -/// We keep adding `(total_rewards * time_passed) / (total_time)` every -/// time someone interacts with the manager. +/// In Suilend we also store the amount of rewards that have been made available to users already. +/// We keep adding `(total_rewards * time_passed) / (total_time)` every time someone interacts with +/// the manager. /// This value is used to transfer the unallocated rewards to the admin. -/// However, this can be calculated dynamically which avoids storing an extra -/// packed [Decimal] on each [PoolReward]. +/// However, this can be calculated dynamically which avoids storing an extra packed [Decimal] on +/// each [PoolReward]. #[derive(Clone, Debug, Default, PartialEq)] pub struct PoolReward { - /// Unique ID for this slot that has never been used before, and will never - /// be used again. + /// Unique ID for this entry that has never been used before, and will never be used again. pub id: PoolRewardId, /// # (Un)Packing - /// When we pack the reward we set this to default pubkey for vacant slots. + /// When we pack the reward we set this to default pubkey for vacant entries. pub vault: Pubkey, /// Monotonically increasing time taken from clock sysvar. pub start_time_secs: u64, /// For how long (since start time) will this reward be releasing tokens. /// - /// # Reward cancellation + /// # Reward Editing /// - /// Is cut short if the reward is cancelled. + /// Is cut short or extended. pub duration_secs: u32, /// Total token amount to distribute. - /// The token account that holds the rewards holds at least this much in - /// the beginning. + /// The token account that holds the rewards holds at least this much in the beginning. + /// + /// # Reward Editing + /// + /// Is deducted or increased linearly to the duration. pub total_rewards: u64, /// How many users are still tracking this reward. /// Once this reaches zero we can close this reward. @@ -129,7 +131,7 @@ impl PoolRewardManager { /// Must last at least [MIN_REWARD_PERIOD_SECS]. /// The amount of tokens to distribute must be greater than zero. /// - /// Will return an error if no slot can be found for the new reward. + /// Will return an error if no entry can be found for the new reward. pub fn add_pool_reward( &mut self, vault: Pubkey, @@ -155,7 +157,7 @@ impl PoolRewardManager { LendingError::MathOverflow })? }; - if MIN_REWARD_PERIOD_SECS > duration_secs as u64 { + if MIN_REWARD_PERIOD_SECS > duration_secs { msg!("Pool reward duration must be at least {MIN_REWARD_PERIOD_SECS} secs"); return Err(LendingError::PoolRewardPeriodTooShort.into()); } @@ -165,24 +167,24 @@ impl PoolRewardManager { return Err(LendingError::InvalidAmount.into()); } - let eligible_slot = + let eligible_entry = self.pool_rewards .iter_mut() .enumerate() - .find_map(|(slot_index, slot)| match slot { - PoolRewardSlot::Vacant { + .find_map(|(entry_index, entry)| match entry { + PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(id), .. - } if *id < u32::MAX => Some((slot_index, PoolRewardId(*id + 1))), + } if *id < u32::MAX => Some((entry_index, PoolRewardId(*id + 1))), _ => None, }); - let Some((slot_index, next_id)) = eligible_slot else { - msg!("No vacant slot found for the new pool reward"); - return Err(LendingError::NoVacantSlotForPoolReward.into()); + let Some((entry_index, next_id)) = eligible_entry else { + msg!("No vacant entry found for the new pool reward"); + return Err(LendingError::NoVacantEntryForPoolReward.into()); }; - self.pool_rewards[slot_index] = PoolRewardSlot::Occupied(Box::new(PoolReward { + self.pool_rewards[entry_index] = PoolRewardEntry::Occupied(Box::new(PoolReward { id: next_id, vault, start_time_secs, @@ -195,44 +197,89 @@ impl PoolRewardManager { Ok(()) } - /// Sets the duration of the pool reward to now. - /// Returns the amount of unallocated rewards and the vault they are in. - pub fn cancel_pool_reward( + /// Change the pool reward end time to `new_end_time_secs`. + /// This way the reward can be extended or shortened. + /// + /// The relative change in the total amount must remain the same, ie. a user wouldn't be able to + /// tell a difference between how much rewards they received over the same period of time. + /// That change in the token amount is what we return along with the vault the rewards are in. + /// + /// Positive change means the admin should add more tokens to the vault, negative means they + /// should transfer tokens out of the vault. + pub fn edit_pool_reward( &mut self, pool_reward_index: usize, + new_end_time_secs: u64, clock: &Clock, - ) -> Result<(Pubkey, u64), ProgramError> { + ) -> Result<(Pubkey, i64), ProgramError> { self.update(clock)?; - let Some(PoolRewardSlot::Occupied(pool_reward)) = + let Some(PoolRewardEntry::Occupied(pool_reward)) = self.pool_rewards.get_mut(pool_reward_index) else { - msg!("Cannot cancel a non-existent pool reward"); + msg!("Cannot edit a non-existent pool reward"); return Err(ProgramError::InvalidArgument); }; if pool_reward.has_ended(clock) { - msg!("Cannot cancel a pool reward that has already ended"); + msg!("Cannot edit a pool reward that has already ended"); return Err(LendingError::InvalidAccountInput.into()); } - let since_start_secs = clock.unix_timestamp as u64 - pool_reward.start_time_secs; - let unlocked_rewards = Decimal::from(pool_reward.total_rewards) - .try_mul(Decimal::from(since_start_secs))? - .try_div(Decimal::from(pool_reward.duration_secs as u64))? - .try_floor_u64()?; - let remaining_rewards = pool_reward.total_rewards - unlocked_rewards; + let new_end_time_secs = new_end_time_secs + .max(clock.unix_timestamp as u64) + .max(pool_reward.start_time_secs); + + let new_duration_secs: u32 = (new_end_time_secs - pool_reward.start_time_secs) + .try_into() + .unwrap_or(u32::MAX) + .max(MIN_REWARD_PERIOD_SECS); + + // we'll use this to calculate how should the total reward change + let rewards_per_seconds = Decimal::from(pool_reward.total_rewards) + .try_div(Decimal::from(pool_reward.duration_secs as u64))?; + + let old_duration_secs = pool_reward.duration_secs; + + pool_reward.duration_secs = new_duration_secs; + match new_duration_secs.cmp(&old_duration_secs) { + Ordering::Equal => { + msg!("Pool reward duration is the same, nothing to do"); + Ok((pool_reward.vault, 0)) + } + Ordering::Greater => { + let extend_by_secs = new_duration_secs - old_duration_secs; + msg!("Extending pool reward duration by {}s", extend_by_secs); + + // ceil up so that we cannot extend a reward without adding more tokens + let rewards_to_add = rewards_per_seconds + .try_mul(Decimal::from(extend_by_secs as u64))? + .try_ceil_u64()?; + + pool_reward.total_rewards += rewards_to_add; + + Ok((pool_reward.vault, rewards_to_add as i64)) + } + Ordering::Less => { + let shorten_by_secs = old_duration_secs - new_duration_secs; + msg!("Shortening pool reward duration by {}s", shorten_by_secs); + + // floor down so that the vault is never short by a token + let rewards_to_remove = rewards_per_seconds + .try_mul(Decimal::from(shorten_by_secs as u64))? + .try_floor_u64()?; - pool_reward.duration_secs = - u32::try_from(since_start_secs).expect("New duration to be strictly shorter"); + pool_reward.total_rewards -= rewards_to_remove; - Ok((pool_reward.vault, remaining_rewards)) + Ok((pool_reward.vault, -(rewards_to_remove as i64))) + } + } } /// Closes a pool reward if it has been cancelled before. /// Returns the vault the rewards are in. pub fn close_pool_reward(&mut self, pool_reward_index: usize) -> Result { - let Some(PoolRewardSlot::Occupied(pool_reward)) = + let Some(PoolRewardEntry::Occupied(pool_reward)) = self.pool_rewards.get_mut(pool_reward_index) else { msg!("Cannot close a non-existent pool reward"); @@ -246,7 +293,7 @@ impl PoolRewardManager { let vault = pool_reward.vault; - self.pool_rewards[pool_reward_index] = PoolRewardSlot::Vacant { + self.pool_rewards[pool_reward_index] = PoolRewardEntry::Vacant { last_pool_reward_id: pool_reward.id, has_been_just_vacated: true, }; @@ -276,7 +323,7 @@ impl PoolRewardManager { .pool_rewards .iter_mut() .filter_map(|r| match r { - PoolRewardSlot::Occupied(reward) => Some(reward), + PoolRewardEntry::Occupied(reward) => Some(reward), _ => None, }) .filter(|r| curr_unix_timestamp_secs > r.start_time_secs) @@ -334,12 +381,12 @@ impl Default for PoolRewardManager { Self { total_shares: 0, last_update_time_secs: 0, - pool_rewards: std::array::from_fn(|_| PoolRewardSlot::default()), + pool_rewards: std::array::from_fn(|_| PoolRewardEntry::default()), } } } -impl Default for PoolRewardSlot { +impl Default for PoolRewardEntry { fn default() -> Self { Self::Vacant { last_pool_reward_id: PoolRewardId(0), @@ -373,22 +420,22 @@ impl Pack for PoolRewardManager { .enumerate() .filter(|(_, s)| s.should_be_packed()); - for (index, pool_reward_slot) in rewards_to_pack { + for (index, pool_reward_entry) in rewards_to_pack { let offset = 16 + index * PoolReward::LEN; let raw_pool_reward_head = array_mut_ref![output, offset, PoolReward::HEAD_LEN]; let (dst_id, dst_vault) = mut_array_refs![raw_pool_reward_head, PoolRewardId::LEN, PUBKEY_BYTES]; - match pool_reward_slot { - PoolRewardSlot::Vacant { + match pool_reward_entry { + PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(id), .. } => { dst_id.copy_from_slice(&id.to_le_bytes()); dst_vault.copy_from_slice(Pubkey::default().as_ref()); } - PoolRewardSlot::Occupied(pool_reward) => { + PoolRewardEntry::Occupied(pool_reward) => { dst_id.copy_from_slice(&pool_reward.id.0.to_le_bytes()); dst_vault.copy_from_slice(pool_reward.vault.as_ref()); @@ -445,7 +492,7 @@ impl Pack for PoolRewardManager { // SAFETY: ok to assign because we know the index is less than length pool_reward_manager.pool_rewards[index] = if vault == Pubkey::default() { - PoolRewardSlot::Vacant { + PoolRewardEntry::Vacant { last_pool_reward_id: pool_reward_id, // nope, has been vacant since unpack has_been_just_vacated: false, @@ -469,7 +516,7 @@ impl Pack for PoolRewardManager { 16 // cumulative_rewards_per_share ]; - PoolRewardSlot::Occupied(Box::new(PoolReward { + PoolRewardEntry::Occupied(Box::new(PoolReward { id: pool_reward_id, vault, start_time_secs: u64::from_le_bytes(*src_start_time_secs), @@ -487,7 +534,7 @@ impl Pack for PoolRewardManager { } } -impl PoolRewardSlot { +impl PoolRewardEntry { /// If we know for sure that data hasn't changed then we can just skip packing. fn should_be_packed(&self) -> bool { let for_sure_has_not_changed = matches!( @@ -516,12 +563,12 @@ mod tests { let is_vacant = rng.gen_bool(0.5); if is_vacant { - PoolRewardSlot::Vacant { + PoolRewardEntry::Vacant { last_pool_reward_id: Default::default(), has_been_just_vacated: false, } } else { - PoolRewardSlot::Occupied(Box::new(PoolReward { + PoolRewardEntry::Occupied(Box::new(PoolReward { id: PoolRewardId(rng.gen()), vault: Pubkey::new_unique(), start_time_secs: rng.gen(), @@ -539,7 +586,7 @@ mod tests { #[test] fn it_packs_id_if_vacated_in_this_tx() { let mut m = PoolRewardManager::default(); - m.pool_rewards[0] = PoolRewardSlot::Vacant { + m.pool_rewards[0] = PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(69), has_been_just_vacated: true, }; @@ -550,7 +597,7 @@ mod tests { assert_eq!( unpacked.pool_rewards[0], - PoolRewardSlot::Vacant { + PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(69), has_been_just_vacated: false, } @@ -567,7 +614,7 @@ mod tests { let all_rewards_are_empty = unpacked.pool_rewards.iter().all(|pool_reward| { matches!( pool_reward, - PoolRewardSlot::Vacant { + PoolRewardEntry::Vacant { last_pool_reward_id: PoolRewardId(0), has_been_just_vacated: false, } diff --git a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs index 558bd09a936..516d277cf45 100644 --- a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs @@ -6,7 +6,7 @@ use crate::{ error::LendingError, math::{Decimal, TryAdd, TryMul, TrySub}, state::{ - pack_decimal, unpack_decimal, PoolRewardId, PoolRewardManager, PoolRewardSlot, + pack_decimal, unpack_decimal, PoolRewardEntry, PoolRewardId, PoolRewardManager, PositionKind, MAX_REWARDS, }, }; @@ -166,7 +166,7 @@ impl UserRewardManager { .iter_mut() .enumerate() .find_map(move |(index, slot)| match slot { - PoolRewardSlot::Occupied(pool_reward) if pool_reward.vault == vault => { + PoolRewardEntry::Occupied(pool_reward) if pool_reward.vault == vault => { Some((index, pool_reward)) } _ => None, @@ -301,7 +301,7 @@ impl UserRewardManager { for (pool_reward_index, pool_reward) in pool_reward_manager.pool_rewards.iter_mut().enumerate() { - let PoolRewardSlot::Occupied(pool_reward) = pool_reward else { + let PoolRewardEntry::Occupied(pool_reward) = pool_reward else { // no reward to track continue; }; From 97ed9dfe3bdda02b15d35f44c7db9725d5d3a311 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Sun, 4 May 2025 11:29:20 +0200 Subject: [PATCH 16/19] [Liquidity Mining] Use reward vault key as part of the seed (13) (#214) * Use reward vault as part of the authority PDA seed * Fixing misleading comments * Marking TODO as completed --- token-lending/LIQUIDITY_MINING.md | 16 +++-- .../program/src/processor/liquidity_mining.rs | 63 +++++++++---------- .../liquidity_mining/add_pool_reward.rs | 3 +- .../liquidity_mining/claim_user_reward.rs | 10 +-- .../liquidity_mining/close_pool_reward.rs | 14 ++--- .../liquidity_mining/edit_pool_reward.rs | 10 +-- .../tests/helpers/solend_program_test.rs | 12 ++-- token-lending/sdk/src/instruction.rs | 34 ++++------ 8 files changed, 72 insertions(+), 90 deletions(-) diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md index cce28cfa425..bc2af6f25f4 100644 --- a/token-lending/LIQUIDITY_MINING.md +++ b/token-lending/LIQUIDITY_MINING.md @@ -50,14 +50,10 @@ A reward vault authority is a PDA that is used to sign CPIs into the token progr [ b"RewardVaultAuthority", lending_market_key, - reserve_key, - reward_mint_key, + vault_token_account_key, ] ``` -> TBD: Should we use the reward vault token account pubkey instead to create a 1-1 relationship between the authority and the vault? -> What will be easier for the clients to use? - ### `add_pool_reward` Admin only ix that adds a new pool reward to a reserve's reward manager, either a deposit or a borrow one. @@ -224,11 +220,13 @@ A lending market will be automatically upgraded on the first mutable ix. ## Outstanding work -- [ ] Review feature parity with Suilend -- [ ] Consider changing the reward vault authority seed +- [x] Review feature parity with Suilend + - Looped rewards are not implemented but that's ok +- [x] Consider changing the reward vault authority seed - [ ] Consider having another admin account to manage the rewards -- [ ] Consider spending some rent to the obligations from the reclaimed merkle-tree reward distributor -- [ ] Discuss CU limits with the Save client team +- [x] Consider spending some rent to the obligations from the reclaimed merkle-tree reward distributor + - We will fund the obligations to support some of the extra rent +- [x] Discuss CU limits with the Save client team diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 1a906ca4306..14c395f79be 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -48,6 +48,7 @@ struct CheckAndUnpackPoolRewardAccounts<'a, 'info> { reward_authority_info: &'a AccountInfo<'info>, lending_market_info: &'a AccountInfo<'info>, token_program_info: &'a AccountInfo<'info>, + reward_token_vault_info: &'a AccountInfo<'info>, } /// Does all the checks of [check_and_unpack_pool_reward_accounts] and additionally: @@ -83,7 +84,7 @@ fn check_and_unpack_pool_reward_accounts_for_admin_ixs<'a, 'info>( /// * ✅ `lending_market_info` unpacks /// * ✅ `token_program_info` matches `lending_market_info` /// * ✅ `reward_mint_info` belongs to the token program -/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` +/// * ✅ `reward_authority_info` is seed of `lending_market_info`, `reward_token_vault_info` fn check_and_unpack_pool_reward_accounts<'a, 'info>( program_id: &Pubkey, bumps: Bumps, @@ -93,6 +94,7 @@ fn check_and_unpack_pool_reward_accounts<'a, 'info>( reward_authority_info, lending_market_info, token_program_info, + reward_token_vault_info, }: CheckAndUnpackPoolRewardAccounts<'a, 'info>, ) -> Result<(LendingMarket, ReserveBorrow<'a, 'info>), ProgramError> { let reserve = ReserveBorrow::new_mut(program_id, reserve_info)?; @@ -121,8 +123,7 @@ fn check_and_unpack_pool_reward_accounts<'a, 'info>( let expected_reward_vault_authority = create_reward_vault_authority( program_id, lending_market_info.key, - reserve_info.key, - reward_mint_info.key, + reward_token_vault_info.key, bumps.reward_authority, )?; if expected_reward_vault_authority != *reward_authority_info.key { @@ -244,7 +245,7 @@ mod tests { .expect_err("Should fail"); } - /// ❌ `reward_authority_info` is seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + /// ❌ `reward_authority_info` is seed of `lending_market_info`, `reward_token_vault_info` #[test] fn test_fails_if_reward_authority_info_is_not_seed() { let (mut account_info_builders, og_bumps) = @@ -256,8 +257,7 @@ mod tests { let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( &crate::id(), &Pubkey::new_unique(), - &account_info_builders.reserve.key, - &account_info_builders.mint.key, + &account_info_builders.reward_token_vault.key, ); account_info_builders.reward_authority.key = new_reward_authority; account_info_builders @@ -270,32 +270,12 @@ mod tests { ) .expect_err("Should fail"); - // wrong reserve + // wrong vault let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( &crate::id(), &account_info_builders.lending_market.key, &Pubkey::new_unique(), - &account_info_builders.mint.key, - ); - account_info_builders.reward_authority.key = new_reward_authority; - account_info_builders - .clone() - .check_and_unpack_pool_reward_accounts( - crate::id(), - Bumps { - reward_authority: new_reward_authority_bump, - }, - ) - .expect_err("Should fail"); - - // wrong mint - - let (new_reward_authority, new_reward_authority_bump) = find_reward_vault_authority( - &crate::id(), - &account_info_builders.lending_market.key, - &account_info_builders.reserve.key, - &Pubkey::new_unique(), ); account_info_builders.reward_authority.key = new_reward_authority; account_info_builders @@ -361,6 +341,7 @@ mod tests { reserve: AccountInfoBuilder, reward_authority: AccountInfoBuilder, token_program: AccountInfoBuilder, + reward_token_vault: AccountInfoBuilder, } #[derive(Clone)] @@ -394,10 +375,10 @@ mod tests { lending_market: lending_market.key, ..Default::default() }); + let reward_token_vault = AccountInfoBuilder::new_reward_token_vault(); let (reward_authority, bumps) = AccountInfoBuilder::new_reward_authority( &lending_market.key, - &reserve.key, - &mint.key, + &reward_token_vault.key, ); ( @@ -408,6 +389,7 @@ mod tests { reserve, reward_authority, token_program, + reward_token_vault, }, bumps, ) @@ -423,6 +405,7 @@ mod tests { let reserve_info = self.reserve.as_account_info(); let reward_authority_info = self.reward_authority.as_account_info(); let token_program_info = self.token_program.as_account_info(); + let reward_token_vault_info = self.reward_token_vault.as_account_info(); check_and_unpack_pool_reward_accounts( &program_id, @@ -433,6 +416,7 @@ mod tests { reward_authority_info: &reward_authority_info, reward_mint_info: &mint_info, token_program_info: &token_program_info, + reward_token_vault_info: &reward_token_vault_info, }, ) .map(drop) @@ -449,6 +433,7 @@ mod tests { let reward_authority_info = self.reward_authority.as_account_info(); let token_program_info = self.token_program.as_account_info(); let lending_market_owner_info = self.lending_market_owner.as_account_info(); + let reward_token_vault_info = self.reward_token_vault.as_account_info(); check_and_unpack_pool_reward_accounts_for_admin_ixs( &program_id, @@ -459,6 +444,7 @@ mod tests { reward_authority_info: &reward_authority_info, reward_mint_info: &mint_info, token_program_info: &token_program_info, + reward_token_vault_info: &reward_token_vault_info, }, &lending_market_owner_info, ) @@ -549,14 +535,12 @@ mod tests { fn new_reward_authority( lending_market_key: &Pubkey, - reserve_key: &Pubkey, - reward_mint_key: &Pubkey, + reward_token_vault_key: &Pubkey, ) -> (Self, Bumps) { let (key, bump) = find_reward_vault_authority( &crate::id(), lending_market_key, - reserve_key, - reward_mint_key, + reward_token_vault_key, ); let s = Self { @@ -590,5 +574,18 @@ mod tests { is_executable: false, } } + + fn new_reward_token_vault() -> Self { + Self { + key: Pubkey::new_unique(), + lamports: 0, + data: vec![], + owner: spl_token::id(), + rent_epoch: 0, + is_signer: false, + is_writable: true, + is_executable: false, + } + } } } diff --git a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs index abcb03d6e45..cd63db66c65 100644 --- a/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/add_pool_reward.rs @@ -44,7 +44,7 @@ struct AddPoolRewardAccounts<'a, 'info> { /// ✅ matches `reward_mint_info` /// ✅ is writable reward_token_source_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` reward_authority_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program /// ✅ has no data @@ -157,6 +157,7 @@ impl<'a, 'info> AddPoolRewardAccounts<'a, 'info> { reward_authority_info, lending_market_info, token_program_info, + reward_token_vault_info, }, lending_market_owner_info, )?; diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index 45dd377e619..d53d6b7cdc0 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -45,8 +45,8 @@ struct ClaimUserReward<'a, 'info> { /// ✅ is writable _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program - reward_mint_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + _reward_mint_info: &'a AccountInfo<'info>, + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` reward_authority_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program /// ✅ unpacks to a [TokenAccount] @@ -123,8 +123,7 @@ pub(crate) fn process( authority_signer_seeds: &[ reward_vault_authority_seeds( accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, + accounts.reward_token_vault_info.key, ) .as_slice(), &[&[reward_authority_bump]], @@ -215,6 +214,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { reward_authority_info, lending_market_info, token_program_info, + reward_token_vault_info, }, )?; @@ -286,7 +286,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { obligation_info, obligation_owner_token_account_info, _reserve_info: reserve_info, - reward_mint_info, + _reward_mint_info: reward_mint_info, reward_authority_info, reward_token_vault_info, lending_market_info, diff --git a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs index cb0309e58ff..31a445a3d91 100644 --- a/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/close_pool_reward.rs @@ -32,15 +32,15 @@ struct ClosePoolRewardAccounts<'a, 'info> { /// ✅ unpacks /// ✅ belongs to `lending_market_info` /// ✅ is writable - reserve_info: &'a AccountInfo<'info>, + _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program - reward_mint_info: &'a AccountInfo<'info>, + _reward_mint_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program /// ✅ owned by `lending_market_owner_info` /// ✅ matches `reward_mint_info` /// ✅ is writable reward_token_destination_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` reward_authority_info: &'a AccountInfo<'info>, /// ❓ we don't know whether it matches vault in the [Reserve] /// ✅ is writable @@ -96,8 +96,7 @@ pub(crate) fn process( let signer_seeds = [ reward_vault_authority_seeds( accounts.lending_market_info.key, - accounts.reserve_info.key, - accounts.reward_mint_info.key, + accounts.reward_token_vault_info.key, ) .as_slice(), &[&bump_seed], @@ -159,6 +158,7 @@ impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { reward_authority_info, lending_market_info, token_program_info, + reward_token_vault_info, }, lending_market_owner_info, )?; @@ -196,8 +196,8 @@ impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { } Ok(Self { - reserve_info, - reward_mint_info, + _reserve_info: reserve_info, + _reward_mint_info: reward_mint_info, reward_token_destination_info, reward_authority_info, reward_token_vault_info, diff --git a/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs index 495616afc0f..c4bb8fb4e82 100644 --- a/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/edit_pool_reward.rs @@ -45,12 +45,12 @@ struct EditPoolRewardAccounts<'a, 'info> { /// ✅ is writable _reserve_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program - reward_mint_info: &'a AccountInfo<'info>, + _reward_mint_info: &'a AccountInfo<'info>, /// ✅ belongs to the token program /// ✅ matches `reward_mint_info` /// ✅ is writable lending_market_reward_token_account_info: &'a AccountInfo<'info>, - /// ✅ seed of `lending_market_info`, `reserve_info`, `reward_mint_info` + /// ✅ seed of `lending_market_info`, `reward_token_vault_info` reward_authority_info: &'a AccountInfo<'info>, /// ❓ we don't know whether it matches the reward vault pubkey stored in [Reserve] /// ✅ is writable @@ -124,8 +124,7 @@ pub(crate) fn process( authority_signer_seeds: &[ reward_vault_authority_seeds( accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, + accounts.reward_token_vault_info.key, ) .as_slice(), &[&[reward_authority_bump]], @@ -160,6 +159,7 @@ impl<'a, 'info> EditPoolRewardAccounts<'a, 'info> { reward_authority_info, lending_market_info, token_program_info, + reward_token_vault_info, }, lending_market_owner_info, )?; @@ -192,7 +192,7 @@ impl<'a, 'info> EditPoolRewardAccounts<'a, 'info> { Ok(Self { _reserve_info: reserve_info, - reward_mint_info, + _reward_mint_info: reward_mint_info, lending_market_reward_token_account_info: reward_token_destination_info, reward_authority_info, reward_token_vault_info, diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 00f44e34f1d..9956e13f529 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -948,8 +948,7 @@ impl Info { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), &self.pubkey, - &reserve.pubkey, - &reward.mint, + &reward.vault.pubkey(), ); let instructions = [ @@ -998,8 +997,7 @@ impl Info { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), &self.pubkey, - &reserve.pubkey, - &reward.mint, + &reward.vault.pubkey(), ); let instructions = [ @@ -1036,8 +1034,7 @@ impl Info { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), &self.pubkey, - &reserve.pubkey, - &reward.mint, + &reward.vault.pubkey(), ); let instructions = [ @@ -1073,8 +1070,7 @@ impl Info { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), &self.pubkey, - &reserve.pubkey, - &reward.mint, + &reward.vault.pubkey(), ); let instructions = [ diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 11adbf39cca..9b203aa5975 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -542,8 +542,7 @@ pub enum LendingInstruction { /// `[]` Derived reserve pool reward authority. Seed: /// * b"RewardVaultAuthority" /// * Lending market account pubkey - /// * Reserve account pubkey - /// * Reward mint pubkey + /// * Vault token account pubkey /// `[writable]` Uninitialized rent-exempt account that will hold reward tokens. /// `[]` Lending market account. /// `[signer]` Lending market owner. @@ -575,8 +574,7 @@ pub enum LendingInstruction { /// `[]` Derived reserve pool reward authority. Seed: /// * b"RewardVaultAuthority" /// * Lending market account pubkey - /// * Reserve account pubkey - /// * Reward mint pubkey + /// * Vault token account pubkey /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[signer]` Lending market owner. @@ -606,8 +604,7 @@ pub enum LendingInstruction { /// `[]` Derived reserve pool reward authority. Seed: /// * b"RewardVaultAuthority" /// * Lending market account pubkey - /// * Reserve account pubkey - /// * Reward mint pubkey + /// * Vault token account pubkey /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[signer]` Lending market owner. @@ -637,8 +634,7 @@ pub enum LendingInstruction { /// `[]` Derived reserve pool reward authority. Seed: /// * b"RewardVaultAuthority" /// * Lending market account pubkey - /// * Reserve account pubkey - /// * Reward mint pubkey + /// * Vault token account pubkey /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[]` Token program. @@ -2272,8 +2268,7 @@ pub fn close_pool_reward( /// `[]` Derived reserve pool reward authority. Seed: /// * b"RewardVaultAuthority" /// * Lending market account pubkey -/// * Reserve account pubkey -/// * Reward mint pubkey +/// * Vault token account pubkey /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[]` Token program. @@ -2314,11 +2309,10 @@ pub fn claim_pool_reward( pub fn find_reward_vault_authority( program_id: &Pubkey, lending_market_key: &Pubkey, - reserve_key: &Pubkey, - reward_mint_key: &Pubkey, + reward_token_vault_key: &Pubkey, ) -> (Pubkey, u8) { Pubkey::find_program_address( - &reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key), + &reward_vault_authority_seeds(lending_market_key, reward_token_vault_key), program_id, ) } @@ -2327,14 +2321,12 @@ pub fn find_reward_vault_authority( pub fn create_reward_vault_authority( program_id: &Pubkey, lending_market_key: &Pubkey, - reserve_key: &Pubkey, - reward_mint_key: &Pubkey, + reward_token_vault_key: &Pubkey, bump: u8, ) -> Result { Pubkey::create_program_address( &[ - reward_vault_authority_seeds(lending_market_key, reserve_key, reward_mint_key) - .as_slice(), + reward_vault_authority_seeds(lending_market_key, reward_token_vault_key).as_slice(), &[&[bump]], ] .concat(), @@ -2345,14 +2337,12 @@ pub fn create_reward_vault_authority( /// Returns seeds to derive the reward vault authority PDA address. pub fn reward_vault_authority_seeds<'keys>( lending_market_key: &'keys Pubkey, - reserve_key: &'keys Pubkey, - reward_mint_key: &'keys Pubkey, -) -> [&'keys [u8]; 4] { + reward_token_vault_key: &'keys Pubkey, +) -> [&'keys [u8]; 3] { [ b"RewardVaultAuthority", lending_market_key.as_ref(), - reserve_key.as_ref(), - reward_mint_key.as_ref(), + reward_token_vault_key.as_ref(), ] } From 81cafd5b2705e2665d4b7840a6a17c6af56eb1a8 Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Tue, 1 Jul 2025 09:53:22 +0200 Subject: [PATCH 17/19] Liquidity Mining: Addressing audit review (#219) * Addressing audit review * Increasing CU limit * Addressing audit review * Addressing clippy suggestions and increasing CU limits --- Cargo.lock | 1 + token-lending/program/Cargo.toml | 5 +- .../liquidity_mining/claim_user_reward.rs | 62 ++++++++++-- .../liquidity_mining/upgrade_reserve.rs | 16 ++- .../program/tests/claim_pool_reward.rs | 31 +++++- .../tests/helpers/solend_program_test.rs | 75 +++++++++++++- token-lending/sdk/src/instruction.rs | 27 ++++-- .../sdk/src/state/liquidity_mining.rs | 48 ++++----- .../liquidity_mining/user_reward_manager.rs | 97 ++++++++++++++----- 9 files changed, 283 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efd858b9cec..afa6411fcfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5448,6 +5448,7 @@ dependencies = [ "solana-program-test", "solana-sdk", "solend-sdk", + "spl-associated-token-account 1.1.3", "spl-token 3.5.0", "static_assertions", "switchboard-on-demand", diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index ae8a379dd44..535d35b1d77 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -15,11 +15,10 @@ test-bpf = [] [dependencies] bytemuck = "1.5.1" -# pyth-sdk-solana = "0.8.0" -# pyth-solana-receiver-sdk = "0.3.0" +oracles = { path = "../oracles" } solana-program = "=1.16.20" solend-sdk = { path = "../sdk" } -oracles = { path = "../oracles" } +spl-associated-token-account = "1.0.5" # compatible with spl-token 3.3.0 spl-token = { version = "3.3.0", features = ["no-entrypoint"] } static_assertions = "1.1.0" diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index d53d6b7cdc0..e8ff96b97d0 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -19,8 +19,9 @@ use solana_program::{ pubkey::Pubkey, sysvar::Sysvar, }; -use solend_sdk::state::{Obligation, PositionKind}; +use solend_sdk::state::{HasRewardEnded, Obligation, PositionKind}; use solend_sdk::{error::LendingError, instruction::reward_vault_authority_seeds}; +use spl_associated_token_account::get_associated_token_address_with_program_id; use super::{ check_and_unpack_pool_reward_accounts, unpack_token_account, Bumps, @@ -29,6 +30,8 @@ use super::{ /// Use [Self::from_unchecked_iter] to validate the accounts. struct ClaimUserReward<'a, 'info> { + /// ✅ is_signer + perhaps_payer_info: Option<&'a AccountInfo<'info>>, /// ✅ belongs to this program /// ✅ unpacks /// ✅ matches `lending_market_info` @@ -37,7 +40,7 @@ struct ClaimUserReward<'a, 'info> { /// ✅ belongs to the token program /// ✅ is writable /// ✅ matches `reward_mint_info` - /// ✅ owned by the obligation owner + /// ✅ is obligation owner's ATA for the reward mint obligation_owner_token_account_info: &'a AccountInfo<'info>, /// ✅ belongs to this program /// ✅ unpacks @@ -88,11 +91,22 @@ pub(crate) fn process( )?; let reserve_key = accounts.reserve.key(); + // AUDIT: + // > ClaimUserReward doesn’t check if the Obligation is stale. + // > This can cause problems for borrow rewards, because the obligation's liability_shares will + // > be stale. + if matches!(position_kind, PositionKind::Borrow) + && accounts.obligation.last_update.is_stale(clock.slot)? + { + msg!("obligation is stale and must be refreshed in the current slot"); + return Err(LendingError::ObligationStale.into()); + } + // 1. let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - if let Some(user_reward_manager) = accounts + if let Some((_, user_reward_manager)) = accounts .obligation .user_reward_managers .find_mut(reserve_key, position_kind) @@ -106,12 +120,23 @@ pub(crate) fn process( // 2. - let total_reward_amount = user_reward_manager.claim_rewards( + let (has_ended, total_reward_amount) = user_reward_manager.claim_rewards( pool_reward_manager, *accounts.reward_token_vault_info.key, clock, )?; + // AUDIT: + // > ClaimUserReward on Suilend can only be called permissionlessly if the reward period is + // > fully elapsed. + let payer_matches_obligation_owner = accounts + .perhaps_payer_info + .map_or(false, |payer| payer.key == &accounts.obligation.owner); + if !matches!(has_ended, HasRewardEnded::Yes) && !payer_matches_obligation_owner { + msg!("User reward manager has not ended, but payer does not match obligation owner"); + return Err(LendingError::InvalidSigner.into()); + } + // 3. if total_reward_amount > 0 { @@ -204,6 +229,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { let reward_token_vault_info = next_account_info(iter)?; let lending_market_info = next_account_info(iter)?; let token_program_info = next_account_info(iter)?; + let perhaps_payer_info = next_account_info(iter).ok(); let (_, reserve) = check_and_unpack_pool_reward_accounts( program_id, @@ -218,6 +244,11 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { }, )?; + if perhaps_payer_info.map(|a| !a.is_signer).unwrap_or(false) { + msg!("Payer account must be a signer"); + return Err(LendingError::InvalidSigner.into()); + } + if obligation_info.owner != program_id { msg!("Obligation provided is not owned by the lending program"); return Err(LendingError::InvalidAccountOwner.into()); @@ -230,6 +261,22 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { return Err(LendingError::InvalidAccountInput.into()); } + // AUDIT: + // > In ClaimUserReward, because this is a permissionless instruction, we recommend + // > validating that obligation_owner_token_account_info is an associated token account + // > (ATA), rather than only a token account owned by the obligation owner. + // > Allowing arbitrary token accounts would require indexing each one, adding unnecessary + // > complexity and risk. + let expected_ata = get_associated_token_address_with_program_id( + &obligation.owner, + reward_mint_info.key, + token_program_info.key, + ); + if expected_ata != *obligation_owner_token_account_info.key { + msg!("Token account for collecting rewards must be ATA"); + return Err(LendingError::InvalidAccountInput.into()); + } + if obligation_owner_token_account_info.owner != token_program_info.key { msg!("Obligation owner token account provided must be owned by the token program"); return Err(LendingError::InvalidTokenOwner.into()); @@ -237,12 +284,6 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { let obligation_owner_token_account = unpack_token_account(&obligation_owner_token_account_info.data.borrow())?; - if obligation_owner_token_account.owner != obligation.owner { - msg!( - "Obligation owner token account owner does not match the obligation owner provided" - ); - return Err(LendingError::InvalidAccountInput.into()); - } if obligation_owner_token_account.mint != *reward_mint_info.key { msg!("Obligation owner token account mint does not match the reward mint provided"); return Err(LendingError::InvalidAccountInput.into()); @@ -283,6 +324,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> { } Ok(Self { + perhaps_payer_info, obligation_info, obligation_owner_token_account_info, _reserve_info: reserve_info, diff --git a/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs index 35e97e5e773..60d5508dc81 100644 --- a/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs +++ b/token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs @@ -11,7 +11,7 @@ use solana_program::{ sysvar::Sysvar, }; use solend_sdk::state::discriminator::AccountDiscriminator; -use solend_sdk::state::RESERVE_LEN_V2_0_2; +use solend_sdk::state::{PROGRAM_VERSION_2_0_2, RESERVE_LEN_V2_0_2}; use solend_sdk::{error::LendingError, state::Reserve}; struct UpgradeReserveAccounts<'a, 'info> { @@ -121,6 +121,20 @@ impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> { return Err(LendingError::InvalidAccountInput.into()); } + // AUDIT: + // > UpgradeReserve should verify that a Reserve was previously stored in the account before + // > performing the upgrade. + // > This doesn’t appear to be exploitable—it would just allow an attacker to create + // > a valid-looking Reserve filled with zeros—but it’s worth addressing. + // > Anybody can create an account owned by solend with size == RESERVE_LEN_V2_0_2 by + // > calling system_instruction::create_account, so you can't only rely on checking the + // > account length && ownership. + // > Asserting that the first byte is equal to the expected old version would fix it. + if reserve_info.data.borrow()[0] != PROGRAM_VERSION_2_0_2 { + msg!("Reserve provided must be a v2.0.2 reserve"); + return Err(LendingError::InvalidAccountInput.into()); + } + if system_program.key != &solana_program::system_program::id() { msg!("System program provided must be the system program"); return Err(LendingError::InvalidAccountInput.into()); diff --git a/token-lending/program/tests/claim_pool_reward.rs b/token-lending/program/tests/claim_pool_reward.rs index 34acd62cd87..092786ca8d8 100644 --- a/token-lending/program/tests/claim_pool_reward.rs +++ b/token-lending/program/tests/claim_pool_reward.rs @@ -116,11 +116,32 @@ async fn test_(position_kind: PositionKind) { .await; // user must have a token account to deposit rewards into ahead of time - user.create_token_account(&reward.mint, &mut test).await; + user.create_associated_token_account(&reward.mint, &mut test) + .await; let balance_checker = BalanceChecker::start(&mut test, &[&TokenAccount(reward.vault.pubkey()), &user]).await; + let err = lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + None, + ) + .await + .expect_err("Cannot claim reward before it ends unless owner"); + + match err.unwrap() { + TransactionError::InstructionError(_, InstructionError::Custom(err_code)) => { + assert_eq!(err_code, LendingError::InvalidSigner as u32); + } + _ => panic!("Expected LendingError::InvalidSigner, got: {:?}", err), + }; + lending_market .claim_pool_reward( &mut test, @@ -129,6 +150,7 @@ async fn test_(position_kind: PositionKind) { &user, &reward, position_kind, + Some(&user), ) .await .expect("Should claim reward"); @@ -233,6 +255,7 @@ async fn test_(position_kind: PositionKind) { &user, &reward, position_kind, + None, ) .await .expect("Should claim reward"); @@ -334,6 +357,7 @@ async fn test_cannot_claim_into_wrong_destination() { &lending_market_owner, // ! wrong &reward, PositionKind::Deposit, + None, ) .await .expect_err("Cannot steal user reward"); @@ -430,7 +454,8 @@ async fn test_migrate_obligation() { .await; // user must have a token account to deposit rewards into ahead of time - user.create_token_account(&reward.mint, &mut test).await; + user.create_associated_token_account(&reward.mint, &mut test) + .await; let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; @@ -447,6 +472,7 @@ async fn test_migrate_obligation() { &user, &reward, PositionKind::Deposit, + None, ) .await .expect("Should claim reward"); @@ -494,6 +520,7 @@ async fn test_migrate_obligation() { &user, &reward, PositionKind::Deposit, + None, ) .await .expect("Should claim reward"); diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index 097a40ede00..b2a649835cf 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -77,7 +77,7 @@ mod cu_budgets { pub(super) const ADD_POOL_REWARD: u32 = 80_017; pub(super) const EDIT_POOL_REWARD: u32 = 80_018; pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; - pub(super) const CLAIM_POOL_REWARD: u32 = 80_020; + pub(super) const CLAIM_POOL_REWARD: u32 = 200_020; } /// This is at most how many bytes can an obligation grow. @@ -446,6 +446,29 @@ impl SolendProgramTest { keypair.pubkey() } + pub async fn create_associated_token_account( + &mut self, + owner: &Pubkey, + mint: &Pubkey, + ) -> Pubkey { + let instructions = [ + spl_associated_token_account::instruction::create_associated_token_account( + &self.context.payer.pubkey(), + owner, + mint, + &spl_token::id(), + ), + ]; + + self.process_transaction(&instructions, None).await.unwrap(); + + spl_associated_token_account::get_associated_token_address_with_program_id( + owner, + mint, + &spl_token::id(), + ) + } + pub async fn mint_to(&mut self, mint: &Pubkey, dst: &Pubkey, amount: u64) { assert!(self.mints.contains_key(mint)); @@ -836,6 +859,30 @@ impl User { } } + pub async fn create_associated_token_account( + &mut self, + mint: &Pubkey, + test: &mut SolendProgramTest, + ) -> Info { + match self + .token_accounts + .iter() + .find(|ta| ta.account.mint == *mint) + { + None => { + let pubkey = test + .create_associated_token_account(&self.keypair.pubkey(), mint) + .await; + let account = test.load_account::(pubkey).await; + + self.token_accounts.push(account.clone()); + + account + } + Some(t) => t.clone(), + } + } + pub async fn transfer( &self, mint: &Pubkey, @@ -1066,6 +1113,7 @@ impl Info { obligation_owner: &User, reward: &LiqMiningReward, position_kind: PositionKind, + signer: Option<&User>, ) -> Result<(), BanksClientError> { let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( &solend_program::id(), @@ -1073,23 +1121,40 @@ impl Info { &reward.vault.pubkey(), ); - let instructions = [ + let mut instructions = if matches!(position_kind, PositionKind::Borrow) { + self.build_refresh_instructions(test, obligation, None) + .await + } else { + vec![] + }; + + instructions.extend_from_slice(&[ ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLAIM_POOL_REWARD), claim_pool_reward( solend_program::id(), reward_authority_bump, position_kind, obligation.pubkey, - obligation_owner.get_account(&reward.mint).unwrap(), + spl_associated_token_account::get_associated_token_address_with_program_id( + &obligation_owner.keypair.pubkey(), + &reward.mint, + &spl_token::id(), + ), reserve.pubkey, reward.mint, reward_authority_pda, reward.vault.pubkey(), self.pubkey, + signer.map(|s| s.keypair.pubkey()), ), - ]; + ]); - test.process_transaction(&instructions, None).await + if let Some(signer) = signer { + test.process_transaction(&instructions, Some(&[&signer.keypair])) + .await + } else { + test.process_transaction(&instructions, None).await + } } pub async fn donate_to_reserve( diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 9b203aa5975..396c9dbb421 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -2284,19 +2284,26 @@ pub fn claim_pool_reward( reward_vault_authority: Pubkey, reward_vault: Pubkey, lending_market: Pubkey, + payer: Option, ) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(obligation, false), + AccountMeta::new(obligation_owner_token_account_for_reward, false), + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(spl_token::id(), false), + ]; + + if let Some(payer) = payer { + accounts.push(AccountMeta::new(payer, true)); + } + Instruction { program_id, - accounts: vec![ - AccountMeta::new(obligation, false), - AccountMeta::new(obligation_owner_token_account_for_reward, false), - AccountMeta::new(reserve, false), - AccountMeta::new_readonly(reward_mint, false), - AccountMeta::new_readonly(reward_vault_authority, false), - AccountMeta::new(reward_vault, false), - AccountMeta::new_readonly(lending_market, false), - AccountMeta::new_readonly(spl_token::id(), false), - ], + accounts, data: LendingInstruction::ClaimReward { reward_authority_bump, position_kind, diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index 19b97d454f1..439439a3b16 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -100,7 +100,7 @@ mod tests { clock.unix_timestamp = reward_period as i64 / 2; - let not_cancelled_claimed_slnd = { + let (_, not_cancelled_claimed_slnd) = { let mut pool_reward_manager = pool_reward_manager.clone(); let mut user_reward_manager = user_reward_manager.clone(); @@ -109,7 +109,7 @@ mod tests { .expect("It claims rewards") }; - let edited_claimed_slnd = { + let (_, edited_claimed_slnd) = { let mut pool_reward_manager = pool_reward_manager.clone(); let mut user_reward_manager = user_reward_manager.clone(); @@ -124,7 +124,7 @@ mod tests { .expect("It claims rewards") }; - let canceled_claimed_slnd = { + let (_, canceled_claimed_slnd) = { let pool_reward_index = 0; pool_reward_manager .cancel_pool_reward(pool_reward_index, &clock) @@ -237,11 +237,11 @@ mod tests { clock.unix_timestamp += rng.gen_range(0..MIN_REWARD_PERIOD_SECS) as i64; for user_reward_manager in &mut user_reward_managers { - let claimed_foo = user_reward_manager + let (_, claimed_foo) = user_reward_manager .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) .expect("It claims foo rewards"); - let claimed_bar = user_reward_manager + let (_, claimed_bar) = user_reward_manager .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) .expect("It claims bar rewards"); @@ -277,11 +277,11 @@ mod tests { let neither_has_ended = !has_foo_ended && !has_bar_ended; for user_reward_manager in &mut user_reward_managers { - let claimed_foo = user_reward_manager + let (_, claimed_foo) = user_reward_manager .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) .expect("It claims foo rewards"); - let claimed_bar = user_reward_manager + let (_, claimed_bar) = user_reward_manager .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) .expect("It claims bar rewards"); @@ -310,12 +310,12 @@ mod tests { // check that no more rewards can be claimed for user_reward_manager in &mut user_reward_managers { - let claimed_foo = user_reward_manager + let (_, claimed_foo) = user_reward_manager .claim_rewards(&mut pool_reward_manager, foo_vault, &clock) .expect("It claims foo rewards"); prop_assert_eq!(claimed_foo, 0); - let claimed_bar = user_reward_manager + let (_, claimed_bar) = user_reward_manager .claim_rewards(&mut pool_reward_manager, bar_vault, &clock) .expect("It claims bar rewards"); prop_assert_eq!(claimed_bar, 0); @@ -420,7 +420,7 @@ mod suilend_tests { // 1/4 of the reward time passes clock.unix_timestamp = 5 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 25 * 1_000_000); @@ -440,12 +440,12 @@ mod suilend_tests { // 1/2 of the reward time passes clock.unix_timestamp = 10 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 5 * 1_000_000); - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 20 * 1_000_000); @@ -461,12 +461,12 @@ mod suilend_tests { // the reward is finished clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 25 * 1_000_000); - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 25 * 1_000_000); @@ -538,22 +538,22 @@ mod suilend_tests { { clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 87_500_000); - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 75 * 1_000_000); - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 12_500_000); - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 25 * 1_000_000); @@ -595,7 +595,7 @@ mod suilend_tests { user_reward_manager_1.set_share(&mut pool_reward_manager, 1); clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); // 50 usdc is unallocated since there was zero share from 0-10 seconds @@ -643,13 +643,13 @@ mod suilend_tests { { clock.unix_timestamp = 20 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 75 * 1_000_000); user_reward_manager_2.set_share(&mut pool_reward_manager, 1); - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 25 * 1_000_000); @@ -732,7 +732,7 @@ mod suilend_tests { { clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_1 + let (_, claimed_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 50 * 1_000_000); @@ -797,7 +797,7 @@ mod suilend_tests { assert_eq!(unallocated_rewards, -50 * 1_000_000 + 1); // approx clock.unix_timestamp = 15 * SECONDS_IN_A_DAY as i64; - let claim_slnd = user_reward_manager_1 + let (_, claim_slnd) = user_reward_manager_1 .claim_rewards(&mut pool_reward_manager, slnd_vault1, &clock) .expect("It claims rewards"); assert_eq!(claim_slnd, 50 * 1_000_000); @@ -819,7 +819,7 @@ mod suilend_tests { { clock.unix_timestamp = 30 * SECONDS_IN_A_DAY as i64; - let claimed_slnd = user_reward_manager_2 + let (_, claimed_slnd) = user_reward_manager_2 .claim_rewards(&mut pool_reward_manager, slnd_vault2, &clock) .expect("It claims rewards"); assert_eq!(claimed_slnd, 50 * 1_000_000); diff --git a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs index 516d277cf45..914362290b6 100644 --- a/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/user_reward_manager.rs @@ -7,7 +7,7 @@ use crate::{ math::{Decimal, TryAdd, TryMul, TrySub}, state::{ pack_decimal, unpack_decimal, PoolRewardEntry, PoolRewardId, PoolRewardManager, - PositionKind, MAX_REWARDS, + PositionKind, MAX_OBLIGATION_RESERVES, MAX_REWARDS, }, }; use arrayref::{array_mut_ref, array_ref, array_refs, mut_array_refs}; @@ -97,11 +97,14 @@ impl UserRewardManagers { &mut self, reserve: Pubkey, position_kind: PositionKind, - ) -> Option<&mut UserRewardManager> { - self.0.iter_mut().find(|user_reward_manager| { - user_reward_manager.reserve == reserve - && user_reward_manager.position_kind == position_kind - }) + ) -> Option<(usize, &mut UserRewardManager)> { + self.0 + .iter_mut() + .enumerate() + .find(|(_, user_reward_manager)| { + user_reward_manager.reserve == reserve + && user_reward_manager.position_kind == position_kind + }) } /// Updates the [UserRewardManager] for the given reserve. @@ -123,7 +126,7 @@ impl UserRewardManagers { new_share: u64, clock: &Clock, ) -> Result<(), ProgramError> { - let user_reward_manager = if let Some(user_reward_manager) = + let (index, user_reward_manager) = if let Some((index, user_reward_manager)) = self.find_mut(reserve, position_kind) { user_reward_manager.update( @@ -131,21 +134,48 @@ impl UserRewardManagers { clock, CreatingNewUserRewardManager::No, )?; - user_reward_manager + + (index, user_reward_manager) + } else if self.len() >= MAX_OBLIGATION_RESERVES { + // AUDIT: + // > Right now the max number of UserRewardManagers is 10 and the max number of rewards + // > is 30, but that's not really enforced because you are using debug_asserts which are + // > ignored in release mode (see MAX_REWARDS and MAX_OBLIGATION_REWARDS). + // > It's only enforced by checking the final packed length is less than + // > Obligation::MAX_LEN, so for example you can have an Obligation with 134 + // > UserRewardManagers, each tracking 1 reward. + msg!("User rewards full, claim rewards to make space."); + return Err(LendingError::ObligationReserveLimit.into()); } else { let mut new_user_reward_manager = UserRewardManager::new(reserve, position_kind, clock); new_user_reward_manager.populate(pool_reward_manager, clock)?; self.0.push(new_user_reward_manager); - // SAFETY: we just pushed a new item to the vector so ok to unwrap - self.0.last_mut().unwrap() + + // SAFETY: we just pushed a new item to the vector + (self.0.len() - 1, self.0.last_mut().unwrap()) }; user_reward_manager.set_share(pool_reward_manager, new_share); + // AUDIT: + // > We believe you should remove UserRewardManager entries when all the earned rewards are + // > claimed and the share is set to 0 (ie there is no corresponding Position in the + // > obligation) + if new_share == 0 && user_reward_manager.rewards.is_empty() { + self.0.swap_remove(index); + } + Ok(()) } } +/// Whether the reward was removed from the user manager. +#[allow(missing_docs)] +pub enum HasRewardEnded { + No, + Yes, +} + impl UserRewardManager { /// Claims all rewards that the user has earned. /// Returns how many tokens should be transferred to the user. @@ -158,7 +188,7 @@ impl UserRewardManager { pool_reward_manager: &mut PoolRewardManager, vault: Pubkey, clock: &Clock, - ) -> Result { + ) -> Result<(HasRewardEnded, u64), ProgramError> { self.update(pool_reward_manager, clock, CreatingNewUserRewardManager::No)?; let (pool_reward_index, pool_reward) = pool_reward_manager @@ -185,22 +215,36 @@ impl UserRewardManager { // User is not tracking this reward, nothing to claim. // Let's be graceful and make this a no-op. // Prevents failures when multiple parties crank rewards. - return Ok(0); + return Ok((HasRewardEnded::Yes, 0)); }; let to_claim = user_reward.withdraw_earned_rewards()?; - if pool_reward.has_ended(clock) && user_reward.earned_rewards.try_floor_u64()? == 0 { + if (self.share == 0 || pool_reward.has_ended(clock)) + && user_reward.earned_rewards.try_floor_u64()? == 0 + { // This reward won't be used anymore as it ended and the user // claimed all there was to claim. // We can clean up this user reward. + + // AUDIT: + // > UserRewards tracked inside UserRewardManager.rewards can only be removed when the + // > pool_reward period has ended and the earned_reward has been claimed. + // > So this means that users are still forced to wait for reward expiration even when + // > they haven't any share. + // > I think you should also make it possible to cleanup the UserRewardManager.rewards + // > when the UserRewardManager.share is set to 0 and + // > UserRewardManager.rewards[i].earned_rewards.floor() == 0 + // We're fine with swap remove bcs `user_reward_index` is meaningless. // SAFETY: We got the index from enumeration, so must exist. self.rewards.swap_remove(user_reward_index); pool_reward.num_user_reward_managers -= 1; - } - Ok(to_claim) + Ok((HasRewardEnded::Yes, to_claim)) + } else { + Ok((HasRewardEnded::No, to_claim)) + } } } @@ -277,6 +321,9 @@ impl UserRewardManager { /// Should be updated before any interaction with rewards. /// + /// We expect the user share to be 0 if they are creating a new user manager. + /// The share is updated later. + /// /// # Assumption /// Invoker has checked that this [PoolRewardManager] matches the /// [UserRewardManager]. @@ -289,11 +336,12 @@ impl UserRewardManager { pool_reward_manager.update(clock)?; let curr_unix_timestamp_secs = clock.unix_timestamp as u64; - - if matches!( + let is_creating_new_reward_manager = matches!( creating_new_reward_manager, - CreatingNewUserRewardManager::No - ) && curr_unix_timestamp_secs == self.last_update_time_secs + CreatingNewUserRewardManager::Yes + ); + + if !is_creating_new_reward_manager && curr_unix_timestamp_secs == self.last_update_time_secs { return Ok(()); } @@ -313,7 +361,8 @@ impl UserRewardManager { .find(|(_, r)| r.pool_reward_index == pool_reward_index); let end_time_secs = pool_reward.start_time_secs + pool_reward.duration_secs as u64; - let has_ended_for_user = self.last_update_time_secs >= end_time_secs; + let has_ended_for_user = (!is_creating_new_reward_manager && self.share == 0) + || self.last_update_time_secs >= end_time_secs; match maybe_user_reward { Some((user_reward_index, user_reward)) @@ -346,6 +395,9 @@ impl UserRewardManager { None if pool_reward.start_time_secs > curr_unix_timestamp_secs => { // reward period has not started yet } + None if self.share == 0 && !is_creating_new_reward_manager => { + // user has no share, nothing to accrue + } None => { // user did not yet start accruing rewards @@ -359,10 +411,7 @@ impl UserRewardManager { .cumulative_rewards_per_share .try_mul(Decimal::from(self.share))? } else { - debug_assert!(matches!( - creating_new_reward_manager, - CreatingNewUserRewardManager::Yes - )); + debug_assert!(is_creating_new_reward_manager); Decimal::zero() }, }; From c163f1113de0ac04f3b629182a55ee720dd8597f Mon Sep 17 00:00:00 2001 From: vanity mnm Date: Tue, 1 Jul 2025 09:56:12 +0200 Subject: [PATCH 18/19] Feature: Liquidity Mining CLI (#218) * CLI has a script that upgrades all reserves * Adding CLI commands * Adding also reward views into the CLI * Note on looped rewards --- Anchor.toml | 101 ++++++ Cargo.lock | 40 ++- token-lending/LIQUIDITY_MINING.md | 6 +- token-lending/cli/Cargo.toml | 17 +- token-lending/cli/src/liquidity_mining.rs | 19 + .../src/liquidity_mining/add_pool_reward.rs | 114 ++++++ .../src/liquidity_mining/close_pool_reward.rs | 104 ++++++ .../liquidity_mining/crank_pool_rewards.rs | 179 ++++++++++ .../src/liquidity_mining/edit_pool_reward.rs | 96 ++++++ .../find_obligations_to_fund.rs | 113 ++++++ .../liquidity_mining/migrate_all_reserves.rs | 94 +++++ .../view_obligation_rewards.rs | 38 ++ .../liquidity_mining/view_reserve_rewards.rs | 86 +++++ token-lending/cli/src/main.rs | 325 ++++++++++++++++-- token-lending/program/Cargo.toml | 2 +- token-lending/program/src/processor.rs | 2 +- .../program/src/processor/liquidity_mining.rs | 2 +- ...im_user_reward.rs => claim_pool_reward.rs} | 0 token-lending/sdk/Cargo.toml | 2 +- token-lending/sdk/src/state/obligation.rs | 13 + token-lending/tests/liquidity-mining.ts | 17 +- 21 files changed, 1301 insertions(+), 69 deletions(-) create mode 100644 token-lending/cli/src/liquidity_mining.rs create mode 100644 token-lending/cli/src/liquidity_mining/add_pool_reward.rs create mode 100644 token-lending/cli/src/liquidity_mining/close_pool_reward.rs create mode 100644 token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs create mode 100644 token-lending/cli/src/liquidity_mining/edit_pool_reward.rs create mode 100644 token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs create mode 100644 token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs create mode 100644 token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs create mode 100644 token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs rename token-lending/program/src/processor/liquidity_mining/{claim_user_reward.rs => claim_pool_reward.rs} (100%) diff --git a/Anchor.toml b/Anchor.toml index bd1b269975a..99fe81ddd78 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -29,3 +29,104 @@ url = "https://api.mainnet-beta.solana.com" [[test.validator.clone]] # Solend Main Pool - (USDC) Reserve State address = "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw" +# What follows is a list of some more reserves to clone to test batch upgrade +[[test.validator.clone]] +address = "46t9bCbiBwiVsjQPz2CLYcMULCtsZTBbwqLdAz7s2xXy" +[[test.validator.clone]] +address = "6RLEnWjEUR8MTTn8MtFXmw1Fz1JWjUVcC1bQFPqDXbgy" +[[test.validator.clone]] +address = "4o8bqVMVrjwbUEU69axoQB7LFDHHt58d2XMtPXFJ8tK1" +[[test.validator.clone]] +address = "3xLmLkoKSKddqg7ejPNq679ApQNnm2dn3VVvtV7isSDo" +[[test.validator.clone]] +address = "44JpnzauMCjmwHBN1VJe4rFVjidozsAeGMR5srzuWp55" +[[test.validator.clone]] +address = "4YA39gkfuskkp3ir1NZ9jySkHYocSnZoPY2oT64BRmb5" +[[test.validator.clone]] +address = "3NdKfP3qLSzxqQTkJpYL2gGBza1esdyakRrAyMLt64R2" +[[test.validator.clone]] +address = "59KjpUPKXsoYZUNdc8g2Yi32uAw9Brm6g8bQCfFVomyJ" +[[test.validator.clone]] +address = "5noExw6LxoDaoAbcaFj5YZbBhpXoMqazzZuBrAUeFiUj" +[[test.validator.clone]] +address = "5eLCVf61tRmv4V6WASMfR7X4skqXpEakqyaLMFrQETSB" +[[test.validator.clone]] +address = "rKBpHeyPyn9YU4VNtxaX6Tu618Y2R4sWybxUpea5ph4" +[[test.validator.clone]] +address = "5yq5AJTJMoRQvGFbA2h6wKRQMV7Z6CjfbZCa3cAJS2r5" +[[test.validator.clone]] +address = "gY2sQUkxEQPDcn2s72KBKmw9q343QpVhxU5WLu2sxjr" +[[test.validator.clone]] +address = "6TsJpaAbwJMTFZdAEHcVb86zQaLoNmbabYX1kkW1NAj7" +[[test.validator.clone]] +address = "6JLJ3eq8sHDjUBLy4zjvLjvyMjrqnUDizQEdcZqgaYrG" +[[test.validator.clone]] +address = "4kgzahtogzibeopWQBrKcCYPiavEAaV9sJjuZy9dUuic" +[[test.validator.clone]] +address = "6uhoHHFPQbRpssDphCjxU6hMmXp3GLS2qAZmCExZkwap" +[[test.validator.clone]] +address = "13Ts1ERfwAM11MVQAU3zCz49fGkWmdZbfXuTGyKz6ENy" +[[test.validator.clone]] +address = "14aRZgQAQtGRES3mNTFvEVEfEqtnKJa73ryENDAvJFaQ" +[[test.validator.clone]] +address = "6e8zg2Y9skA5AMm7J62GJQ8Ui4reuzEytBS74zHV8S7A" +[[test.validator.clone]] +address = "6QNF4ovs4vWqjjvNUNoJLhXCW34iEm2QGipRKJdk725n" +[[test.validator.clone]] +address = "yjuWAA6XXhEwxWuzbPfDPnYiohCLFqAfgCDA4EdHHDm" +[[test.validator.clone]] +address = "XK8FEMEziMX9W46ivFmjddjvW3aY8dkVvucEGLmrt5D" +[[test.validator.clone]] +address = "2WBEmjZbUMXZbs9ucG3B7254y2C34d72uv2qHjvR1T4n" +[[test.validator.clone]] +address = "yxW7QwpJzKxfNo2QkbcmgjpgFxEL4UGbUivkFWtkmd3" +[[test.validator.clone]] +address = "66RtjhW1bMXTJ2ZL8TMowUTAtraHiksKGGQSArGRZKAZ" +[[test.validator.clone]] +address = "3Cv8evqFV1MWirL1ohv2VsdTAmpDvNWy4veGjYDFrWn2" +[[test.validator.clone]] +address = "5rDqwn1GMMrSkjvZS1G7BZ2tR5Q4JS13wnSHn11La9a9" +[[test.validator.clone]] +address = "4ERjFetPd5DQDK8N9wL4i36ozs4zeW1MeGbPbKw9QMsy" +[[test.validator.clone]] +address = "5hevPuvhqmXdQcuiB2mqekxQcqL9kVix8D5ckRGyA8yk" +[[test.validator.clone]] +address = "62M3oYeJ2agvuHsgHfzJuiWsTbEewZCFJbMHXKDkKMqL" +[[test.validator.clone]] +address = "6DF8vRKZKdAK2rdirJTwG3TWogjgByqcEi5WbnwXb3YZ" +[[test.validator.clone]] +address = "3mSMHPvNewL8RTwAcA6GTCLjS19J3NK2huJxgA553oHy" +[[test.validator.clone]] +address = "75EgKN1rrVssMQr6KjvR5w6Gnth8FkqECgZ6Q4mDDCx6" +[[test.validator.clone]] +address = "2tSNdecgEHEidEemg9vCbgFPzg6nyok97xJc1Lse3mG8" +[[test.validator.clone]] +address = "3Hr5qshXDQgbL1za6Sayug61Hwt5rjnV7dbyE9NUQDaZ" +[[test.validator.clone]] +address = "2v3Y19ahC3dtV6CnrmT5vZfpJy7HkyFoxkRgTeuk6cC4" +[[test.validator.clone]] +address = "53oro4QCCqqtDgfs1qfeH5LyvfdSexjXXaT14drTJ2Xj" +[[test.validator.clone]] +address = "7kL41rV8tgRBticXUyp3LfCV7eBGKUrAwL2e7GJB5ooP" +[[test.validator.clone]] +address = "7t3QnGAqse8zvAohJJX7robsBviyP5Bg7xNBxi7HSPNy" +[[test.validator.clone]] +address = "7vcWs9Gut1HE8o24cfuYkjuFCdBMEkBALqqvLDnQsBQT" +[[test.validator.clone]] +address = "2j8XVnUFk6Hxm75QdJn6MDyxVE9QCbCe5BauGg82ANZU" +[[test.validator.clone]] +address = "81EGVb5RD8yft1N3SGxitn2QnvJg7Pbq5k4CiXkf3f5A" +[[test.validator.clone]] +address = "7UgZy6RzhfSG66qrdD7Q5LHrVJRm7bUqhRHPh9siBqQQ" +[[test.validator.clone]] +address = "3kWqRMVepJ5HSmXF16bBWQYQ5C7YNGvJJqCPt3A7zKWH" +[[test.validator.clone]] +address = "8NVfgFqWPiy7B4o4yQQ8XSTwSihidtdftA1wzeePnCeJ" +[[test.validator.clone]] +address = "3738W1f4ygKayow8TrFGuDbhFovQ3QhM2MPY8AF375ki" +[[test.validator.clone]] +address = "7ssc2gVnucKKwsh6DS4HYHUWAigJomW6v37T65SXJVEr" +[[test.validator.clone]] +address = "2wDArvF5bAdLmmm4QZNVFJDrML6CCBbfbCeCLEb7c6QN" +[[test.validator.clone]] +address = "41SrrxMb1yzivSbUNjLShRV5yZf7S7YdQ1Emg2AxCviu" diff --git a/Cargo.lock b/Cargo.lock index afa6411fcfd..1f770e26255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,7 +1143,7 @@ dependencies = [ "bitflags 1.3.2", "strsim 0.8.0", "textwrap 0.11.0", - "unicode-width", + "unicode-width 0.1.13", "vec_map", ] @@ -1203,7 +1203,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.13", "windows-sys 0.52.0", ] @@ -2355,24 +2355,15 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.17.8" +version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ "console", - "instant", "number_prefix", "portable-atomic", - "unicode-width", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", + "unicode-width 0.2.0", + "web-time", ] [[package]] @@ -5463,6 +5454,7 @@ version = "2.1.0" dependencies = [ "bincode", "clap 2.34.0", + "indicatif", "reqwest 0.12.4", "serde_json", "solana-account-decoder", @@ -6166,7 +6158,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" dependencies = [ - "unicode-width", + "unicode-width 0.1.13", ] [[package]] @@ -6614,6 +6606,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.1.0" @@ -6872,6 +6870,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki" version = "0.22.4" diff --git a/token-lending/LIQUIDITY_MINING.md b/token-lending/LIQUIDITY_MINING.md index bc2af6f25f4..1f676658952 100644 --- a/token-lending/LIQUIDITY_MINING.md +++ b/token-lending/LIQUIDITY_MINING.md @@ -39,6 +39,10 @@ We keep adding `(total_rewards * time_passed) / (total_time)` every time someone This value is used to transfer the unallocated rewards to the admin. However, this can be calculated dynamically which avoids storing an extra packed decimal (16 bytes) on each reserve's pool reward (30). +In Suilend, we disable looped rewards. +For example, if an obligation has reserve $USDC and $USDT, this obligation cannot claim rewards. +This is not done in Save. + ## New ixs There's a common concept of reward vault and reward vault authority across the ixs. @@ -73,7 +77,7 @@ Users will still be able to claim rewards they accrued until this point. #### Cancel Cancelling a pool reward can be done by setting the end time to 0. -Note that only rewards longer than [solend_sdk::MIN_REWARD_PERIOD_SECS] can be cancelled. +Note that only rewards longer than `solend_sdk::MIN_REWARD_PERIOD_SECS` can be cancelled. In this case we transfer tokens from the reward vault to the lending market reward token account. #### Shorten diff --git a/token-lending/cli/Cargo.toml b/token-lending/cli/Cargo.toml index a59aa534750..0c024029373 100644 --- a/token-lending/cli/Cargo.toml +++ b/token-lending/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program-cli" -version.workspace = true +version = "2.1.0" authors = ["Solend Maintainers "] description = "Solend Program CLI" edition = "2018" @@ -9,21 +9,22 @@ license = "Apache-2.0" repository = "https://github.com/solendprotocol/solana-program-library" [dependencies] +bincode = "1.3.3" clap = "=2.34.0" +indicatif = "0.17.11" +reqwest = { version = "0.12.2", features = ["blocking", "json"] } +serde_json = "1.0.120" +solana-account-decoder = "1.14.10" solana-clap-utils = "1.14.10" solana-cli-config = "1.14.10" solana-client = "1.14.10" solana-logger = "1.14.10" -solana-sdk = "1.14.10" solana-program = "1.14.10" -solend-sdk = { path = "../sdk" } +solana-sdk = "1.14.10" solend-program = { path = "../program", features = ["no-entrypoint"] } -spl-token = { version = "3.3.0", features = ["no-entrypoint"] } +solend-sdk = { path = "../sdk" } spl-associated-token-account = "1.0" -solana-account-decoder = "1.14.10" -reqwest = { version = "0.12.2", features = ["blocking", "json"] } -bincode = "1.3.3" -serde_json = "1.0.120" +spl-token = { version = "3.3.0", features = ["no-entrypoint"] } [[bin]] name = "solend-cli" diff --git a/token-lending/cli/src/liquidity_mining.rs b/token-lending/cli/src/liquidity_mining.rs new file mode 100644 index 00000000000..e1cb75c819d --- /dev/null +++ b/token-lending/cli/src/liquidity_mining.rs @@ -0,0 +1,19 @@ +//! CLI commands related to liquidity mining. + +mod add_pool_reward; +mod close_pool_reward; +mod crank_pool_rewards; +mod edit_pool_reward; +mod find_obligations_to_fund; +mod migrate_all_reserves; +mod view_obligation_rewards; +mod view_reserve_rewards; + +pub(crate) use add_pool_reward::command as command_add_pool_reward; +pub(crate) use close_pool_reward::command as command_close_pool_reward; +pub(crate) use crank_pool_rewards::command as command_crank_pool_rewards; +pub(crate) use edit_pool_reward::command as command_edit_pool_reward; +pub(crate) use find_obligations_to_fund::command as command_find_obligations_to_fund_for_liquidity_mining; +pub(crate) use migrate_all_reserves::command as command_migrate_all_reserves_for_liquidity_mining; +pub(crate) use view_obligation_rewards::command as command_view_obligation_rewards; +pub(crate) use view_reserve_rewards::command as command_view_reserve_rewards; diff --git a/token-lending/cli/src/liquidity_mining/add_pool_reward.rs b/token-lending/cli/src/liquidity_mining/add_pool_reward.rs new file mode 100644 index 00000000000..1d337cab55d --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/add_pool_reward.rs @@ -0,0 +1,114 @@ +//! Adds a pool reward to a reserve. +//! +//! The signer must be the owner of the lending market, and there must be a free slot in the reserve. + +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::{pubkey::Pubkey, signature::Keypair, signer::Signer, system_instruction}; +use solend_sdk::{ + instruction::{add_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + source_reward_token_account_pubkey: Pubkey, + start_time_secs: u64, + duration_secs: u32, + token_amount: u64, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let has_free_slot = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .iter() + .any(|pr| matches!(pr, PoolRewardEntry::Vacant { .. })); + + if !has_free_slot { + return Err( + "There are no vacant slots to add the pool reward. Please crank it first".into(), + ); + } + + let Some(source_reward_token_account) = config + .rpc_client + .get_token_account(&source_reward_token_account_pubkey)? + else { + return Err(format!( + "Failed to fetch source token account '{}'", + source_reward_token_account_pubkey + ) + .into()); + }; + + let reward_mint = Pubkey::from_str(&source_reward_token_account.mint)?; + + let reward_vault_keypair = Keypair::new(); + + let create_account_ix = system_instruction::create_account( + &config.fee_payer.pubkey(), + &reward_vault_keypair.pubkey(), + config + .rpc_client + .get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN)?, + spl_token::state::Account::LEN as _, + &spl_token::id(), + ); + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &reward_vault_keypair.pubkey(), + ); + + let add_reward_ix = add_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + start_time_secs, + start_time_secs + duration_secs as u64, + token_amount, + reserve_pubkey, + reward_mint, + source_reward_token_account_pubkey, + reward_vault_authority, + reward_vault_keypair.pubkey(), + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[create_account_ix, add_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/close_pool_reward.rs b/token-lending/cli/src/liquidity_mining/close_pool_reward.rs new file mode 100644 index 00000000000..109be55f268 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/close_pool_reward.rs @@ -0,0 +1,104 @@ +//! A pool reward can only be closed if it has no more active user reward managers. + +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{close_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + destination_reward_token_account_pubkey: Pubkey, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let PoolRewardEntry::Occupied(pool_reward) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .get(pool_reward_index) + .ok_or_else(|| { + format!( + "Pool reward index {} does not exist for position kind {:?}", + pool_reward_index, position_kind + ) + })? + else { + return Err("Pool reward index is not occupied".into()); + }; + + if pool_reward.num_user_reward_managers > 0 { + return Err(format!( + "Pool reward still has {} user reward managers. Crank it first.", + pool_reward.num_user_reward_managers + ) + .into()); + } + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + let close_reward_ix = close_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + reserve_pubkey, + reward_mint, + destination_reward_token_account_pubkey, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[close_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs new file mode 100644 index 00000000000..72a56f7e17a --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs @@ -0,0 +1,179 @@ +//! Each reserve has a limited number of entries that are used to declare pool rewards. +//! When all entries are occupied, the admin can no longer start new pool rewards. +//! +//! This is where cranking comes in. +//! Given a reserve, this command estimates the cheapest pool reward to crank out. +//! It loads each obligation and checks if it's tracking the pool reward. +//! Then it performs a claim on behalf of those obligation. + +use indicatif::ProgressIterator; +use solana_client::{ + rpc_config::RpcProgramAccountsConfig, + rpc_filter::{Memcmp, RpcFilterType}, +}; +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{claim_pool_reward, find_reward_vault_authority}, + state::{ + discriminator::AccountDiscriminator, Obligation, PoolRewardEntry, PositionKind, Reserve, + }, +}; +use spl_associated_token_account::{ + get_associated_token_address, instruction::create_associated_token_account_idempotent, +}; +use std::{borrow::Borrow, str::FromStr, time::SystemTime}; + +use crate::{send_transaction, CommandResult, Config}; + +/// How many claim ixs to send in a single transaction. +/// +/// Will be determined empirically. +const CLAIM_IXS_BATCH_SIZE: usize = 4; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + + // since the time onchain is approximate, pick only those pool rewards that are over for sure + // to avoid cranking for nothing + let now_minus_an_hour_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs() + - 3_600; + + // first find a pool reward with the least number of user reward managers + + let Some((pool_reward_index, pool_reward)) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .iter() + .enumerate() + .filter_map(|(index, pr)| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some((index, pr)) + }) + .filter(|(_, pr)| now_minus_an_hour_secs >= pr.start_time_secs + pr.duration_secs as u64) + .min_by_key(|(_, pr)| pr.num_user_reward_managers) + else { + println!("No pool rewards found for reserve '{reserve_pubkey}' ({position_kind:?})"); + return Ok(()); + }; + + // now let's find the reward mint and other info about the vault + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + // let's get all obligations + + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::Memcmp(Memcmp::new_raw_bytes( + 0, + vec![AccountDiscriminator::Obligation as u8], + ))]), + with_context: Some(false), + ..Default::default() + }; + let all_obligations = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + // and filter only those that are still tracking the pool reward + + let ixs: Vec<_> = all_obligations + .into_iter() + .filter_map(|(pubkey, info)| { + // get only those that can be unpacked + Some(( + pubkey, + Obligation::unpack(&info.data) + .inspect_err(|e| { + eprintln!("Failed to unpack obligation account '{pubkey}': {e:?}") + }) + .ok()?, + )) + }) + .filter(|(_, obligation)| { + // get only those that are tracking the pool reward + obligation + .user_reward_managers + .iter() + .filter(|m| m.reserve == reserve_pubkey) + .filter(|m| m.position_kind == position_kind) + .any(|m| { + m.rewards + .iter() + .find(|r| r.pool_reward_index == pool_reward_index) + .map(|r| r.pool_reward_id) + == Some(pool_reward.id) + }) + }) + .map(|(obligation_pubkey, obligation)| (obligation_pubkey, obligation.owner)) + .flat_map(|(obligation_pubkey, obligation_owner)| { + let ata = get_associated_token_address(&obligation_owner, &reward_mint); + + let create_ata_ix = create_associated_token_account_idempotent( + &config.fee_payer.as_ref().pubkey(), + &obligation_owner, + &reward_mint, + &spl_token::id(), + ); + + let claim_ix = claim_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + obligation_pubkey, + ata, + reserve_pubkey, + reward_mint, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + ); + + std::iter::once(create_ata_ix).chain(std::iter::once(claim_ix)) + }) + .collect(); + + for ixs in ixs.chunks(CLAIM_IXS_BATCH_SIZE).progress() { + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + ixs, + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs b/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs new file mode 100644 index 00000000000..1e13acd35e3 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/edit_pool_reward.rs @@ -0,0 +1,96 @@ +use std::{borrow::Borrow, str::FromStr}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::{ + instruction::{edit_pool_reward, find_reward_vault_authority}, + state::{LendingMarket, PoolRewardEntry, PositionKind, Reserve}, +}; + +use crate::{send_transaction, CommandResult, Config}; + +pub(crate) fn command( + config: &mut Config, + reserve_pubkey: Pubkey, + position_kind: PositionKind, + pool_reward_index: usize, + new_end_time_secs: u64, + reward_token_account_pubkey: Pubkey, +) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + let lending_market_info = config.rpc_client.get_account(&reserve.lending_market)?; + let lending_market = LendingMarket::unpack(lending_market_info.data.borrow())?; + + if config.fee_payer.pubkey() != lending_market.owner { + return Err(format!( + "The fee payer must be the owner of the lending market '{}'", + reserve.lending_market + ) + .into()); + } + + let PoolRewardEntry::Occupied(pool_reward) = reserve + .pool_reward_manager(position_kind) + .pool_rewards + .get(pool_reward_index) + .ok_or_else(|| { + format!( + "Pool reward index {} does not exist for position kind {:?}", + pool_reward_index, position_kind + ) + })? + else { + return Err("Pool reward index is not occupied".into()); + }; + + let Some(reward_vault_token_account) = + config.rpc_client.get_token_account(&pool_reward.vault)? + else { + return Err(format!( + "Failed to fetch pool reward vault '{}'", + pool_reward.vault + ))?; + }; + + let reward_mint = Pubkey::from_str(&reward_vault_token_account.mint)?; + + let (reward_vault_authority, reward_authority_bump) = find_reward_vault_authority( + &config.lending_program_id, + &reserve.lending_market, + &pool_reward.vault, + ); + + let edit_reward_ix = edit_pool_reward( + config.lending_program_id, + reward_authority_bump, + position_kind, + pool_reward_index as _, + new_end_time_secs, + reserve_pubkey, + reward_mint, + reward_token_account_pubkey, + reward_vault_authority, + pool_reward.vault, + reserve.lending_market, + lending_market.owner, + ); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = solana_sdk::message::Message::new_with_blockhash( + &[edit_reward_ix], + Some(&config.fee_payer.pubkey()), + &recent_blockhash, + ); + + let transaction = solana_sdk::transaction::Transaction::new( + &vec![config.fee_payer.as_ref()], + message, + recent_blockhash, + ); + + send_transaction(config, transaction)?; + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs b/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs new file mode 100644 index 00000000000..d481fa85c46 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/find_obligations_to_fund.rs @@ -0,0 +1,113 @@ +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use solana_account_decoder::UiAccountEncoding; +use solana_account_decoder::UiDataSliceConfig; +use solana_client::rpc_config::RpcAccountInfoConfig; +use solana_client::rpc_config::RpcProgramAccountsConfig; +use solana_client::rpc_filter::RpcFilterType; +use solana_sdk::native_token::lamports_to_sol; +use solana_sdk::native_token::LAMPORTS_PER_SOL; +use solend_sdk::state::Obligation; + +use crate::CommandResult; +use crate::Config; + +pub(crate) fn command(config: &mut Config, output_csv: impl AsRef) -> CommandResult { + let rent_for_2_0_2 = config + .rpc_client + .get_minimum_balance_for_rent_exemption(Obligation::MIN_LEN)?; + let rent_for_overhead = config + .rpc_client + .get_minimum_balance_for_rent_exemption(1)?; + let rent_per_reserve = config + .rpc_client + .get_minimum_balance_for_rent_exemption(50)?; + + // obligations before migration were sized to Obligation::MIN_LEN and we're only interested in those + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::DataSize(Obligation::MIN_LEN as _)]), + with_context: Some(false), + account_config: RpcAccountInfoConfig { + data_slice: Some(UiDataSliceConfig { + offset: 10 + 32 * 2 + 16 * 7 + 1 + 1 + 14, + length: 2, // first byte for deposits len, second for borrows len + }), + encoding: Some(UiAccountEncoding::Base64), + ..Default::default() + }, + }; + let all_obligations = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + println!("Found {} obligations in total", all_obligations.len()); + + let obligations_that_need_rent: Vec<_> = all_obligations + .into_iter() + .filter_map(|(pubkey, account)| { + assert_eq!(account.data.len(), 2); + let deposits_count = account.data[0] as usize; + let borrows_count = account.data[1] as usize; + assert!(deposits_count + borrows_count <= 10); + let positions_count = deposits_count + borrows_count; + + if positions_count == 0 { + None + } else { + Some((pubkey, positions_count, account.lamports)) + } + }) + .map(|(pubkey, positions_count, current_rent)| { + let extra_rent = current_rent - rent_for_2_0_2; + let required_extra_rent = rent_for_overhead + rent_per_reserve * positions_count as u64; + + let extra_rent_to_add = required_extra_rent.saturating_sub(extra_rent); + + (pubkey, extra_rent_to_add) + }) + .filter(|(_, extra_rent_to_add)| *extra_rent_to_add > 0) + .collect(); + + println!( + "Found {} obligations that need rent", + obligations_that_need_rent.len() + ); + + let missing_rent: u64 = obligations_that_need_rent + .iter() + .map(|(_, extra_rent_to_add)| *extra_rent_to_add) + .sum(); + + println!( + "We'll spend ~{:.2} $SOL on rent", + missing_rent as f64 / LAMPORTS_PER_SOL as f64 + ); + println!( + "Writing the amounts to CSV file at '{}'", + output_csv.as_ref().display() + ); + let mut file = File::create(output_csv.as_ref())?; + + // write the header used by the tokens CLI + writeln!(file, "recipient,amount,lockup_date")?; + + for (recipient, lamports) in obligations_that_need_rent { + writeln!(file, "{},{},", recipient, lamports_to_sol(lamports))?; + } + + println!("Done!"); + println!("Use to distribute the rent"); + println!(); + println!( + "$ solana-tokens distribute-tokens --input-csv {} --from --fee-payer ", + output_csv + .as_ref() + .canonicalize() + .expect("canonicalize") + .display() + ); + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs b/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs new file mode 100644 index 00000000000..ac8adcffe6f --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/migrate_all_reserves.rs @@ -0,0 +1,94 @@ +//! Temporary command that migrates all reserves to their new version. +//! +//! Delete once @v2.1.0 is fully deployed. +//! +//! Running this command before the upgrade: +//! > Found 1621 reserves to upgrade +//! > We'll spend ~54.46 $SOL on rent +//! > There are 87 reserves that were not used in the last 7 days as of 2025-05-08. + +use solana_account_decoder::UiAccountEncoding; +use solana_account_decoder::UiDataSliceConfig; +use solana_client::rpc_config::RpcAccountInfoConfig; +use solana_client::rpc_config::RpcProgramAccountsConfig; +use solana_client::rpc_filter::RpcFilterType; +use solana_sdk::compute_budget::ComputeBudgetInstruction; +use solana_sdk::message::Message; +use solana_sdk::native_token::LAMPORTS_PER_SOL; +use solana_sdk::program_pack::Pack; +use solana_sdk::transaction::Transaction; +use solend_sdk::instruction::upgrade_reserve_to_v2_1_0; +use solend_sdk::state::Reserve; +use solend_sdk::state::RESERVE_LEN_V2_0_2; + +use crate::send_transaction; +use crate::CommandResult; +use crate::Config; + +/// How many reserves to upgrade in a single transaction. +/// +/// We found the right value empirically. +const BATCH_SIZE: usize = 25; +/// How much to pay for compute units. +/// Helps lending txs. +const CU_PRICE: u64 = 3000; + +/// Upgrades all reserves to the new version. +pub(crate) fn command(config: &mut Config) -> CommandResult { + let reserve_new_rent = config + .rpc_client + .get_minimum_balance_for_rent_exemption(Reserve::LEN)?; + + // reserves before migration were sized to RESERVE_LEN_V2_0_2 and we're only interested in those + let filter = RpcProgramAccountsConfig { + filters: Some(vec![RpcFilterType::DataSize(RESERVE_LEN_V2_0_2 as _)]), + // with_context: Some(false), + account_config: RpcAccountInfoConfig { + data_slice: Some(UiDataSliceConfig { + offset: 1, + length: 8, // we don't need the data + }), + encoding: Some(UiAccountEncoding::Base64), + ..Default::default() + }, + ..Default::default() + }; + let reserves_to_upgrade = config + .rpc_client + .get_program_accounts_with_config(&config.lending_program_id, filter)?; + + println!("Found {} reserves to upgrade", reserves_to_upgrade.len()); + + let missing_rent: u64 = reserves_to_upgrade + .iter() + .map(|(_, acc)| reserve_new_rent.saturating_sub(acc.lamports)) + .sum(); + + println!( + "We'll spend ~{:.2} $SOL on rent", + missing_rent as f64 / LAMPORTS_PER_SOL as f64 + ); + + for reserves in reserves_to_upgrade.chunks(BATCH_SIZE) { + let mut ixs = vec![ComputeBudgetInstruction::set_compute_unit_price(CU_PRICE)]; + ixs.extend(reserves.iter().map(|(reserve_pubkey, _)| { + upgrade_reserve_to_v2_1_0( + config.lending_program_id, + *reserve_pubkey, + config.fee_payer.pubkey(), + ) + })); + + let recent_blockhash = config.rpc_client.get_latest_blockhash()?; + + let message = + Message::new_with_blockhash(&ixs, Some(&config.fee_payer.pubkey()), &recent_blockhash); + + let transaction = + Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash); + + send_transaction(config, transaction)?; + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs b/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs new file mode 100644 index 00000000000..f8c35558545 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/view_obligation_rewards.rs @@ -0,0 +1,38 @@ +use std::{borrow::Borrow, time::SystemTime}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::state::{Obligation, Reserve}; + +use crate::{CommandResult, Config}; + +pub(crate) fn command(config: &mut Config, obligation_pubkey: Pubkey) -> CommandResult { + let obligation_info = config.rpc_client.get_account(&obligation_pubkey)?; + let obligation = Obligation::unpack_from_slice(obligation_info.data.borrow())?; + + let now_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + + for user_manager in obligation.user_reward_managers.iter() { + let reserve_info = config.rpc_client.get_account(&user_manager.reserve)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + println!( + "Rewards for reserve {} {:?} last updated {}s ago", + user_manager.reserve, + user_manager.position_kind, + now_secs.saturating_sub(user_manager.last_update_time_secs) + ); + + let pool_reward_manager = reserve.pool_reward_manager(user_manager.position_kind); + + let share = user_manager.share as f64 / pool_reward_manager.total_shares as f64; + println!( + " Mines {}% in {} rewards", + share * 100.0, + user_manager.rewards.len() + ); + } + + Ok(()) +} diff --git a/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs b/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs new file mode 100644 index 00000000000..a2e0eb4dd31 --- /dev/null +++ b/token-lending/cli/src/liquidity_mining/view_reserve_rewards.rs @@ -0,0 +1,86 @@ +use std::{borrow::Borrow, time::SystemTime}; + +use solana_program::program_pack::Pack; +use solana_sdk::pubkey::Pubkey; +use solend_sdk::state::{PoolReward, PoolRewardEntry, PoolRewardManager, Reserve, MAX_REWARDS}; + +use crate::{CommandResult, Config}; + +pub(crate) fn command(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult { + let reserve_info = config.rpc_client.get_account(&reserve_pubkey)?; + let reserve = Reserve::unpack_from_slice(reserve_info.data.borrow())?; + + println!(); + println!("=== Borrow Rewards ==="); + print_pool_rewards(&reserve.borrows_pool_reward_manager)?; + + println!(); + println!("=== Deposit Rewards ==="); + print_pool_rewards(&reserve.deposits_pool_reward_manager)?; + + Ok(()) +} + +fn print_pool_rewards(manager: &PoolRewardManager) -> CommandResult { + // since the time onchain is approximate, pick only those pool rewards that are over for sure + // to avoid cranking for nothing + let now_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs(); + + let open_count = manager + .pool_rewards + .iter() + .filter_map(|pr| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some(pr) + }) + .filter(|pr| now_secs < pr.start_time_secs + pr.duration_secs as u64) + .count(); + + println!("Total shares amount to {}.", manager.total_shares); + println!("There are {open_count}/{MAX_REWARDS} pool rewards running."); + manager + .pool_rewards + .iter() + .enumerate() + .filter_map(|(index, pr)| { + let PoolRewardEntry::Occupied(pr) = pr else { + return None; + }; + Some((index, *pr.clone())) + }) + .for_each( + |( + index, + PoolReward { + id, + vault, + start_time_secs, + duration_secs, + total_rewards, + cumulative_rewards_per_share, + num_user_reward_managers, + }, + )| { + println!("{index}) Pool reward {id:?}:"); + println!(" Vault: {vault}"); + println!(" Start time: {start_time_secs}"); + println!(" Duration: {duration_secs}"); + let ends_in = + duration_secs.saturating_sub(now_secs.saturating_sub(start_time_secs) as _); + if ends_in > 0 { + println!(" Ends in {ends_in}s"); + } else { + println!(" Ended"); + } + println!(" Total rewards: {total_rewards}"); + println!(" Cumulative rewards per share: {cumulative_rewards_per_share}"); + println!(" Number of user reward managers: {num_user_reward_managers}"); + }, + ); + + Ok(()) +} diff --git a/token-lending/cli/src/main.rs b/token-lending/cli/src/main.rs index afa365e7325..de9ae5d7345 100644 --- a/token-lending/cli/src/main.rs +++ b/token-lending/cli/src/main.rs @@ -1,7 +1,15 @@ +mod lending_state; +mod liquidity_mining; + +use std::path::PathBuf; +use std::time::SystemTime; + use lending_state::SolendState; +use liquidity_mining::*; use serde_json::Value; use solana_account_decoder::UiAccountEncoding; +use solana_clap_utils::input_validators::is_amount_or_all; use solana_client::rpc_config::{RpcProgramAccountsConfig, RpcSendTransactionConfig}; use solana_client::{rpc_config::RpcAccountInfoConfig, rpc_filter::RpcFilterType}; use solana_sdk::bs58; @@ -11,7 +19,7 @@ use solend_program::{ instruction::set_lending_market_owner_and_config, state::{validate_reserve_config, RateLimiterConfig}, }; -use solend_sdk::instruction::upgrade_reserve_to_v2_1_0; +use solend_sdk::state::PositionKind; use solend_sdk::{ instruction::{ liquidate_obligation_and_redeem_reserve_collateral, redeem_reserve_collateral, @@ -21,8 +29,6 @@ use solend_sdk::{ state::ReserveType, }; -mod lending_state; - use { clap::{ crate_description, crate_name, crate_version, value_t, App, AppSettings, Arg, ArgMatches, @@ -770,8 +776,138 @@ fn main() { ) ) .subcommand( - SubCommand::with_name("upgrade-reserve") - .about("Migrate reserve to version 2.1.0") + SubCommand::with_name("migrate-all-reserves-for-liquidity-mining") + .about("Upgrade all reserves to version v2.1.0") + ) + .subcommand( + SubCommand::with_name("find-obligations-to-fund-for-liquidity-mining") + .about("Finds obligations which need funding for migration to v2.1.0 and writes them to a CSV file") + .arg(Arg::with_name("output_csv") + .long("output-csv") + .validator(|s| PathBuf::from_str(&s).map(drop).map_err(|_| "Invalid output CSV path".to_string())) + .value_name("PATH") + .takes_value(true) + .required(true) + .help("Output CSV file to write obligations to")) + ) + .subcommand( + SubCommand::with_name("crank-rewards") + .about("Cranks liquidity mining rewards for a given reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg(Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'")) + ) + .subcommand( + SubCommand::with_name("add-pool-reward") + .about("Adds a new liquidity mining reward to a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg(Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'")) + .arg( + Arg::with_name("source") + .long("source") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to deposit rewards from"), + ) + .arg( + Arg::with_name("amount") + .long("amount") + .validator(is_amount_or_all) + .value_name("INTEGER_AMOUNT") + .takes_value(true) + .required(true) + .help("Amount of rewards to distribute (can be ALL)"), + ) + .arg( + Arg::with_name("start_time_secs") + .long("start-time-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .help("Start time in seconds since epoch, defaults to now"), + ) + .arg( + Arg::with_name("duration_secs") + .long("duration-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Duration in seconds"), + ) + ) + .subcommand( + SubCommand::with_name("close-pool-reward") + .about("Closes a liquidity mining reward for a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + .arg( + Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'") + ) + .arg( + Arg::with_name("pool_reward_index") + .long("pool-reward-index") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Index of the pool reward to close"), + ) + .arg( + Arg::with_name("destination") + .long("destination") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to withdraw rewards to"), + ) + ) + .subcommand( + SubCommand::with_name("edit-pool-reward") + .about("Changes a liquidity mining reward for a reserve") .arg( Arg::with_name("reserve") .long("reserve") @@ -781,6 +917,68 @@ fn main() { .required(true) .help("Reserve address"), ) + .arg( + Arg::with_name("position_kind") + .long("position-kind") + .validator(is_parsable::) + .value_name("POSITION_KIND") + .takes_value(true) + .required(true) + .help("Either 'deposit' or 'borrow'") + ) + .arg( + Arg::with_name("pool_reward_index") + .long("pool-reward-index") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("Index of the pool reward to close"), + ) + .arg( + Arg::with_name("new_end_time_secs") + .long("new-end-time-secs") + .validator(is_parsable::) + .value_name("INTEGER") + .takes_value(true) + .required(true) + .help("New end time in seconds since epoch"), + ) + .arg( + Arg::with_name("token_account") + .long("token-account") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("SPL Token account to either credit or debit rewards from"), + ) + ) + .subcommand( + SubCommand::with_name("view-reserve-rewards") + .about("View liquidity mining rewards for a reserve") + .arg( + Arg::with_name("reserve") + .long("reserve") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Reserve address"), + ) + ) + .subcommand( + SubCommand::with_name("view-obligation-rewards") + .about("View liquidity mining rewards for an obligation") + .arg( + Arg::with_name("obligation") + .long("obligation") + .validator(is_pubkey) + .value_name("PUBKEY") + .takes_value(true) + .required(true) + .help("Obligation address"), + ) ) .subcommand( SubCommand::with_name("update-reserve") @@ -1338,10 +1536,96 @@ fn main() { risk_authority_pubkey, ) } - ("upgrade-reserve", Some(arg_matches)) => { - let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); - - command_upgrade_reserve_to_v2_1_0(&mut config, reserve_pubkey) + ("migrate-all-reserves-for-liquidity-mining", _) => { + command_migrate_all_reserves_for_liquidity_mining(&mut config) + } + ("find-obligations-to-fund-for-liquidity-mining", Some(arg_matches)) => { + let output_csv: PathBuf = + value_of(arg_matches, "output_csv").expect("Should include --output-csv file path"); + command_find_obligations_to_fund_for_liquidity_mining(&mut config, &output_csv) + } + ("crank-rewards", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position-_ind") + .expect("Should include --position-kind"); + command_crank_pool_rewards(&mut config, reserve_pubkey, position_kind) + } + ("add-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let source_reward_token_account_pubkey = + pubkey_of(arg_matches, "source").expect("Should include --source"); + let start_time_secs = value_of(arg_matches, "start_time_secs").unwrap_or( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("System time before UNIX EPOCH") + .as_secs(), + ); + let duration_secs = + value_of(arg_matches, "duration_secs").expect("Should include --duration-secs"); + let token_amount = value_of(arg_matches, "amount").expect("Should include --amount"); + + command_add_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + source_reward_token_account_pubkey, + start_time_secs, + duration_secs, + token_amount, + ) + } + ("close-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let pool_reward_index = value_of::(arg_matches, "pool_reward_index") + .expect("Should include --pool-reward-index"); + let destination_reward_token_account_pubkey = + pubkey_of(arg_matches, "destination").expect("Should include --destination"); + + command_close_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + pool_reward_index as _, + destination_reward_token_account_pubkey, + ) + } + ("edit-pool-reward", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + let position_kind = value_of::(arg_matches, "position_kind") + .expect("Should include --position-kind"); + let pool_reward_index = value_of::(arg_matches, "pool_reward_index") + .expect("Should include --pool-reward-index"); + let new_end_time_secs = value_of(arg_matches, "new_end_time_secs") + .expect("Should include --new-end-time-secs"); + let reward_token_account_pubkey = + pubkey_of(arg_matches, "token_account").expect("Should include --token-account"); + + command_edit_pool_reward( + &mut config, + reserve_pubkey, + position_kind, + pool_reward_index as _, + new_end_time_secs, + reward_token_account_pubkey, + ) + } + ("view-obligation-rewards", Some(arg_matches)) => { + let obligation_pubkey = + pubkey_of(arg_matches, "obligation").expect("Should include --obligation"); + command_view_obligation_rewards(&mut config, obligation_pubkey) + } + ("view-reserve-rewards", Some(arg_matches)) => { + let reserve_pubkey = + pubkey_of(arg_matches, "reserve").expect("Should include --reserve"); + command_view_reserve_rewards(&mut config, reserve_pubkey) } ("update-reserve", Some(arg_matches)) => { let reserve_pubkey = pubkey_of(arg_matches, "reserve").unwrap(); @@ -1992,29 +2276,6 @@ fn command_set_lending_market_owner_and_config( Ok(()) } -fn command_upgrade_reserve_to_v2_1_0(config: &mut Config, reserve_pubkey: Pubkey) -> CommandResult { - let recent_blockhash = config.rpc_client.get_latest_blockhash()?; - - let message = Message::new_with_blockhash( - &[ - ComputeBudgetInstruction::set_compute_unit_price(30101), - upgrade_reserve_to_v2_1_0( - config.lending_program_id, - reserve_pubkey, - config.fee_payer.pubkey(), - ), - ], - Some(&config.fee_payer.pubkey()), - &recent_blockhash, - ); - - let transaction = Transaction::new(&vec![config.fee_payer.as_ref()], message, recent_blockhash); - - send_transaction(config, transaction)?; - - Ok(()) -} - #[allow(clippy::too_many_arguments, clippy::unnecessary_unwrap)] fn command_update_reserve( config: &mut Config, diff --git a/token-lending/program/Cargo.toml b/token-lending/program/Cargo.toml index 535d35b1d77..5d2fb9dfb2a 100644 --- a/token-lending/program/Cargo.toml +++ b/token-lending/program/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-program" -version.workspace = true +version = "2.1.0" description = "Solend Program" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 80f06ac8ce7..64c5b22b98b 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -260,7 +260,7 @@ pub fn process_instruction( position_kind, } => { msg!("Instruction: Claim Reward"); - liquidity_mining::claim_user_reward::process( + liquidity_mining::claim_pool_reward::process( program_id, reward_authority_bump, position_kind, diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 14c395f79be..69f2d4ce829 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -20,7 +20,7 @@ //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 pub(crate) mod add_pool_reward; -pub(crate) mod claim_user_reward; +pub(crate) mod claim_pool_reward; pub(crate) mod close_pool_reward; pub(crate) mod edit_pool_reward; pub(crate) mod upgrade_reserve; diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs similarity index 100% rename from token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs rename to token-lending/program/src/processor/liquidity_mining/claim_pool_reward.rs diff --git a/token-lending/sdk/Cargo.toml b/token-lending/sdk/Cargo.toml index 26ddb0536e7..4002f16f5a1 100644 --- a/token-lending/sdk/Cargo.toml +++ b/token-lending/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solend-sdk" -version.workspace = true +version = "2.1.0" description = "Solend Sdk" authors = ["Solend Maintainers "] repository = "https://github.com/solendprotocol/solana-program-library" diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index c88a95ccb59..313b3aed9c5 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -15,6 +15,7 @@ use solana_program::{ use std::{ cmp::{min, Ordering}, convert::{TryFrom, TryInto}, + str::FromStr, }; /// Max number of collateral and liquidity reserve accounts combined for an obligation @@ -840,6 +841,18 @@ impl TryFrom for PositionKind { } } +impl FromStr for PositionKind { + type Err = LendingError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "deposit" => Ok(PositionKind::Deposit), + "borrow" => Ok(PositionKind::Borrow), + _ => Err(LendingError::InstructionUnpackError), + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts index d8cd3aa2c8c..c9e029aa14e 100644 --- a/token-lending/tests/liquidity-mining.ts +++ b/token-lending/tests/liquidity-mining.ts @@ -14,20 +14,21 @@ describe("liquidity mining", () => { // Configure the client to use the local cluster. anchor.setProvider(anchor.AnchorProvider.env()); - const TEST_RESERVE_FOR_UPGRADE = - "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"; - - it("Upgrades reserve to 2.1.0 via CLI", async () => { - // There's an ix that upgrades a reserve to 2.1.0. + it("Upgrades reserves to 2.1.0 via CLI", async () => { + // There's an ix that upgrades all program reserves to 2.1.0. // This ix is invocable via our CLI. // In this test case for comfort and more test coverage we invoke the CLI // command rather than crafting the ix ourselves. + // We check this reserve before & after the upgrade. + const SOME_TEST_RESERVE_TO_CHECK = + "BgxfHJDzm44T7XG68MYKx7YisTjZu73tVovyZSjJMpmw"; + const rpcUrl = anchor.getProvider().connection.rpcEndpoint; const reserveBefore = await anchor .getProvider() - .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + .connection.getAccountInfo(new PublicKey(SOME_TEST_RESERVE_TO_CHECK)); expect(reserveBefore.data.length).to.eq(619); // old version data length const expectedRentBefore = await anchor @@ -36,7 +37,7 @@ describe("liquidity mining", () => { // some reserves have more rent expect(reserveBefore.lamports).to.be.greaterThanOrEqual(expectedRentBefore); - const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} upgrade-reserve --reserve ${TEST_RESERVE_FOR_UPGRADE}`; + const command = `cargo run --quiet --bin solend-cli -- --url ${rpcUrl} migrate-all-reserves-for-liquidity-mining`; console.log(`\$ ${command}`); const cliProcess = exec(command); @@ -58,7 +59,7 @@ describe("liquidity mining", () => { const reserveAfter = await anchor .getProvider() - .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); + .connection.getAccountInfo(new PublicKey(SOME_TEST_RESERVE_TO_CHECK)); expect(reserveAfter.data.length).to.eq(5451); // new version data length const expectedRentAfter = await anchor From 57e982aed2fe3c277a816706324691ed1d4ceed0 Mon Sep 17 00:00:00 2001 From: vanity Date: Tue, 1 Jul 2025 10:02:01 +0200 Subject: [PATCH 19/19] Fixing merge issue with CLI changes --- .../cli/src/liquidity_mining/crank_pool_rewards.rs | 1 + .../sdk/proptest-regressions/state/liquidity_mining.txt | 7 +++++++ token-lending/sdk/src/instruction.rs | 3 +++ 3 files changed, 11 insertions(+) create mode 100644 token-lending/sdk/proptest-regressions/state/liquidity_mining.txt diff --git a/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs index 72a56f7e17a..27a28136e47 100644 --- a/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs +++ b/token-lending/cli/src/liquidity_mining/crank_pool_rewards.rs @@ -151,6 +151,7 @@ pub(crate) fn command( reward_vault_authority, pool_reward.vault, reserve.lending_market, + None, // no payer => permissionless claim ); std::iter::once(create_ata_ix).chain(std::iter::once(claim_ix)) diff --git a/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt b/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt new file mode 100644 index 00000000000..85c05d3ebdc --- /dev/null +++ b/token-lending/sdk/proptest-regressions/state/liquidity_mining.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6a1836cf0547a44279bf151c5fb919eb5549fa1f5b07782ac9da2a3ddbdd73ec # shrinks to rng_seed = 1807223968525307359, user_count = 2, reward_period = 19190, total_rewards = 1000000 diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 396c9dbb421..a7f7e2c6edd 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -2272,6 +2272,9 @@ pub fn close_pool_reward( /// `[writable]` Reward vault token account. /// `[]` Lending market account. /// `[]` Token program. +/// +/// If payer is not provided then this is a permission-less claim. +/// The ix will fail if the reward has not ended yet. #[allow(clippy::too_many_arguments)] pub fn claim_pool_reward( program_id: Pubkey,