From 9e76b2630ae3b491c01999d782f87cf0ee21b9be Mon Sep 17 00:00:00 2001 From: vanity mnm <vanity@solend.fi> Date: Fri, 7 Mar 2025 10:54:45 +0100 Subject: [PATCH 01/14] 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 <vanity@solend.fi> Date: Fri, 14 Mar 2025 13:02:31 +0100 Subject: [PATCH 02/14] [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<Option<UserReward>>, +} + +/// 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::<PoolReward>(); + + 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<Self, ProgramError> { - 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<Self, ProgramError> { - 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 <vanity@solend.fi> Date: Fri, 14 Mar 2025 13:04:44 +0100 Subject: [PATCH 03/14] [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, LendingError> { + 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<Reserve>, + + _priv: (), + } + + impl AddPoolRewardParams { + pub(super) fn new( + position_kind: PositionKind, + start_time_secs: u64, + end_time_secs: u64, + reward_token_amount: u64, + ) -> Result<Self, ProgramError> { + 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<Item = &'a AccountInfo<'info>>, + ) -> Result<AddPoolRewardAccounts<'a, 'info>, 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<Reserve>, + + _priv: (), + } + + impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + params: &CancelPoolRewardParams, + iter: &mut impl Iterator<Item = &'a AccountInfo<'info>>, + ) -> Result<CancelPoolRewardAccounts<'a, 'info>, 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<Reserve>, + } + + impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { + pub(super) fn from_unchecked_iter( + program_id: &Pubkey, + params: &ClosePoolRewardParams, + iter: &mut impl Iterator<Item = &'a AccountInfo<'info>>, + ) -> Result<ClosePoolRewardAccounts<'a, 'info>, 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<Box<Reserve>, 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<LendingError> 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<T>(input: &[u8]) -> Result<(T, &[u8]), ProgramError> + where + T: TryFrom<u8>, + ProgramError: From<<T as TryFrom<u8>>::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<u8> for PositionKind { + type Error = ProgramError; + + fn try_from(value: u8) -> Result<Self, Self::Error> { + 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 <vanity@solend.fi> Date: Fri, 14 Mar 2025 13:07:18 +0100 Subject: [PATCH 04/14] [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 <vanity@solend.fi> Date: Fri, 14 Mar 2025 13:10:09 +0100 Subject: [PATCH 05/14] [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 <maintainers@solend.fi>"] 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 <maintainers@solend.fi>"] 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, LendingError> { 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<Item = &'a AccountInfo<'info>>, + ) -> Result<UpgradeReserveAccounts<'a, 'info>, 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 <maintainers@solend.fi>"] 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<PoolReward>), } /// 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::<Self>(); +} + +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<Box<Self>, 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<Self, ProgramError> { + 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<Value = PoolRewardManager> { + (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::<PoolReward>(); - - 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<PoolRewardManager>, + /// 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<PoolRewardManager>, } 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<Self, ProgramError> { - 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<number>((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 <vanity@solend.fi> Date: Fri, 21 Mar 2025 13:34:37 +0100 Subject: [PATCH 06/14] [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>(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::<Obligation>(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::<Obligation>(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::<Obligation>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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::<Obligation>(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<T: Pack>( &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<T: Pack>( - &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<Obligation> { + 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<T: Pod + Copy>(&mut self, acc_pk: Pubkey) -> Info<T> { 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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { user: &User, ) -> Result<Info<Obligation>, 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<LendingMarket> { .process_transaction(&instructions, Some(&[&obligation_keypair, &user.keypair])) .await { - Ok(()) => Ok(test - .load_account::<Obligation>(obligation_keypair.pubkey()) - .await), + Ok(()) => Ok(test.load_obligation(obligation_keypair.pubkey()).await), Err(e) => Err(e), } } @@ -1034,7 +1068,9 @@ impl Info<LendingMarket> { 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<LendingMarket> { ) -> 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<LendingMarket> { obligation: &Info<Obligation>, extra_reserve: Option<&Info<Reserve>>, ) -> Vec<Instruction> { - let obligation = test.load_account::<Obligation>(obligation.pubkey).await; + let obligation = test.load_obligation(obligation.pubkey).await; let reserve_pubkeys: Vec<Pubkey> = { let mut r = HashSet::new(); r.extend( @@ -1159,7 +1195,9 @@ impl Info<LendingMarket> { 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<LendingMarket> { host_fee_receiver_pubkey: Option<Pubkey>, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::<Obligation>(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<LendingMarket> { 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<LendingMarket> { reserve: &Info<Reserve>, ) -> 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<LendingMarket> { 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<LendingMarket> { user: &User, collateral_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::<Obligation>(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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { reserve: &Info<Reserve>, liquidity_amount: u64, ) -> Result<(), BanksClientError> { - let obligation = test.load_account::<Obligation>(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>(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>(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>(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>(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<LendingMarket>, 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<LendingMarket>, 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::<Obligation>(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::<Obligation>(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>(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>(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::<Obligation>(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>(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>(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>(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>(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>(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>(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::<Obligation>(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::<Obligation>(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::<Obligation>(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>(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>(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>(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>(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>(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::<Reserve>(usdc_reserve.pubkey).await; assert_eq!(usdc_reserve_post.account, usdc_reserve.account); - let obligation_post = test.load_account::<Obligation>(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::<Reserve>(usdc_reserve.pubkey).await; assert_eq!(usdc_reserve_post.account, usdc_reserve.account); - let obligation_post = test.load_account::<Obligation>(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>(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<LendingError> 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<Option<UserReward>>, + /// 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<UserReward>, } /// 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<Self, ProgramError> { + 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<Value = PoolRewardManager> { - (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<Value = UserRewardManager> { + (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<u8> for AccountDiscriminator { + type Error = LendingError; + + fn try_from(value: u8) -> Result<Self, Self::Error> { + 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, Self::Error> { + 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<UserRewardManager>, } /// 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<Self, ProgramError> { + 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<Self, ProgramError> { + 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<Self, ProgramError> { + 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<Self, ProgramError> { + /// Since @v2.1.0 we unpack vector of user reward managers + pub fn unpack_from_slice(src: &[u8]) -> Result<Self, ProgramError> { 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<Value = (u8, u8)> { From 17ac78400e0e48c7dc0e2ee90797468fa4a87bdc Mon Sep 17 00:00:00 2001 From: vanity mnm <vanity@solend.fi> Date: Thu, 27 Mar 2025 10:35:11 +0100 Subject: [PATCH 07/14] [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<TokenAccount, LendingError> { @@ -303,6 +39,8 @@ fn unpack_token_account(data: &[u8]) -> Result<TokenAccount, LendingError> { } /// 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<Reserve>, - - _priv: (), - } - - impl AddPoolRewardParams { - pub(super) fn new( - position_kind: PositionKind, - start_time_secs: u64, - end_time_secs: u64, - reward_token_amount: u64, - ) -> Result<Self, ProgramError> { - 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<Item = &'a AccountInfo<'info>>, - ) -> Result<AddPoolRewardAccounts<'a, 'info>, 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<Reserve>, - - _priv: (), - } - - impl<'a, 'info> CancelPoolRewardAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - params: &CancelPoolRewardParams, - iter: &mut impl Iterator<Item = &'a AccountInfo<'info>>, - ) -> Result<CancelPoolRewardAccounts<'a, 'info>, 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<Reserve>, - } - - impl<'a, 'info> ClosePoolRewardAccounts<'a, 'info> { - pub(super) fn from_unchecked_iter( - program_id: &Pubkey, - params: &ClosePoolRewardParams, - iter: &mut impl Iterator<Item = &'a AccountInfo<'info>>, - ) -> Result<ClosePoolRewardAccounts<'a, 'info>, 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<Reserve>), 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<Item = &'a AccountInfo<'info>>, - ) -> Result<UpgradeReserveAccounts<'a, 'info>, 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<Box<Reserve>, ProgramError> { +) -> Result<(LendingMarket, Box<Reserve>), 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<Reserve>, +} + +/// # 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<Self, ProgramError> { + 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<Item = &'a AccountInfo<'info>>, + ) -> Result<AddPoolRewardAccounts<'a, 'info>, 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<Reserve>, +} + +/// # 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<Item = &'a AccountInfo<'info>>, + ) -> Result<CancelPoolRewardAccounts<'a, 'info>, 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<Obligation>, + reserve: Box<Reserve>, +} + +/// # 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<Item = &'a AccountInfo<'info>>, + ) -> Result<ClaimUserReward<'a, 'info>, 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<Reserve>, + 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<Item = &'a AccountInfo<'info>>, + ) -> Result<ClosePoolRewardAccounts<'a, 'info>, 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<Item = &'a AccountInfo<'info>>, + ) -> Result<UpgradeReserveAccounts<'a, 'info>, 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<LendingError> 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<Pubkey, ProgramError> { + 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<u64, ProgramError> { + 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<u64, ProgramError> { + 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<Self, ProgramError> { + #[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<PositionKind, ProgramError> { + 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<Self, ProgramError> { - 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 <vanity@solend.fi> Date: Fri, 28 Mar 2025 20:55:35 +0100 Subject: [PATCH 08/14] 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<Self, ProgramError> { - 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<Item = &'a AccountInfo<'info>>, ) -> Result<AddPoolRewardAccounts<'a, 'info>, 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<LendingError> 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 <vanity@solend.fi> Date: Sun, 6 Apr 2025 16:53:34 +0200 Subject: [PATCH 09/14] [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<Pubkey>, Option<Pubkey>), 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<Reserve>, + ), + RefMut(std::cell::RefMut<'a, &'info mut [u8]>, Box<Reserve>), +} + +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<Reserve>; + + 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<Self, ProgramError> { + 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<Self, ProgramError> { + 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<T>( + &mut self, + f: impl FnOnce() -> Result<T, ProgramError>, + ) -> Result<T, ProgramError> { + 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, LendingError> { 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<Reserve>), 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<Reserve>), 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>, + 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>, + 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<Obligation>, - reserve: Box<Reserve>, + 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>, + 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::<Reserve>(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::<Reserve>(wsol_reserve.pubkey).await; + let mut wsol_reserve_post = test.load_account::<Reserve>(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::<Reserve>(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::<Reserve>(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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { 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<LendingMarket> { .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<LendingMarket> { 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<LendingMarket> { .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::<Reserve>(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::<Reserve>(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::<Reserve>(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::<Reserve>(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<UserRewardManager>); + /// 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<u64, ProgramError> { + 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<UserRewardManager>; + + 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<Value = PoolRewardManager> { (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<UserRewardManager>, + 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<u64, ProgramError> { 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<u64, ProgramError> { 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<u64, ProgramError> { + 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 <vanity@solend.fi> Date: Mon, 7 Apr 2025 20:13:42 +0200 Subject: [PATCH 10/14] [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::<Reserve>(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<T> { 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<LendingMarket> { .await } + pub async fn add_pool_reward( + &self, + test: &mut SolendProgramTest, + reserve: &Info<Reserve>, + 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, solana_program::pubkey::PubkeyError> { + 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<PoolReward>), -} - -/// 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<UserRewardManager>); - -/// 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<UserReward>, -} - -/// 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<Pubkey, ProgramError> { - 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<u64, ProgramError> { - 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::<Self>(); -} - -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<Box<Self>, 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<Self, ProgramError> { - 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<u64, ProgramError> { - 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<Self, ProgramError> { - #[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<UserRewardManager>; - - 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<Value = PoolRewardManager> { - (0..1u32).prop_perturb(|_, mut rng| PoolRewardManager::new_rand(&mut rng)) - } - - fn user_reward_manager_strategy() -> impl Strategy<Value = UserRewardManager> { - (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<PoolReward>), +} + +/// 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<Pubkey, ProgramError> { + 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::<Self>(); +} + +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<Box<Self>, 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<Self, ProgramError> { + 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<Value = PoolRewardManager> { + (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<UserRewardManager>); + +/// 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<UserReward>, +} + +/// 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<u64, ProgramError> { + 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<Self, ProgramError> { + #[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<u64, ProgramError> { + 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<Vec<UserRewardManager>> for UserRewardManagers { + fn from(user_reward_managers: Vec<UserRewardManager>) -> Self { + Self(user_reward_managers) + } +} + +impl From<UserRewardManagers> for Vec<UserRewardManager> { + fn from(user_reward_managers: UserRewardManagers) -> Self { + user_reward_managers.0 + } +} + +impl Deref for UserRewardManagers { + type Target = Vec<UserRewardManager>; + + 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<Value = UserRewardManager> { + (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::<Vec<_>>() + .into() }, } } From 8037676a7b2d17ec2e3615571681f33de56f45eb Mon Sep 17 00:00:00 2001 From: vanity mnm <vanity@solend.fi> Date: Thu, 10 Apr 2025 11:14:34 +0200 Subject: [PATCH 11/14] [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, LendingError> { 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<Item = &'a AccountInfo<'info>>, ) -> Result<AddPoolRewardAccounts<'a, 'info>, 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<Item = &'a AccountInfo<'info>>, ) -> Result<CancelPoolRewardAccounts<'a, 'info>, 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<Item = &'a AccountInfo<'info>>, ) -> Result<ClaimUserReward<'a, 'info>, 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<Item = &'a AccountInfo<'info>>, ) -> Result<ClosePoolRewardAccounts<'a, 'info>, 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::<Reserve>(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::<Reserve>(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<LendingMarket> { &self, test: &mut SolendProgramTest, reserve: &Info<Reserve>, - 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<LendingMarket> { ), add_pool_reward( solend_program::id(), + reward_authority_bump, position_kind, start_time_secs, end_time_secs, @@ -948,20 +960,91 @@ impl Info<LendingMarket> { 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<Reserve>, + 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<Reserve>, + 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 <vanity@solend.fi> Date: Thu, 10 Apr 2025 16:33:41 +0200 Subject: [PATCH 12/14] [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::<Reserve>(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::<Reserve>(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::<Reserve>(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::<Reserve>(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<LendingMarket> { .await } + pub async fn claim_pool_reward( + &self, + test: &mut SolendProgramTest, + obligation: &Info<Obligation>, + reserve: &Info<Reserve>, + 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<Self, ProgramError> { - 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 <vanity@solend.fi> Date: Sun, 13 Apr 2025 16:48:48 +0200 Subject: [PATCH 13/14] 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<u8>, + 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<LendingMarket> 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<Mint> 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<Reserve> 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 <vanity@solend.fi> Date: Tue, 15 Apr 2025 19:07:43 +0200 Subject: [PATCH 14/14] 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 + +<!-- List of References --> + +[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