diff --git a/token-lending/program/src/error.rs b/token-lending/program/src/error.rs index a4a419a8..ccf483b0 100644 --- a/token-lending/program/src/error.rs +++ b/token-lending/program/src/error.rs @@ -125,6 +125,17 @@ pub enum LendingError { /// Token burn failed #[error("Token burn failed")] TokenBurnFailed, + + // 35 + /// Invalid obligation collateral amount + #[error("Invalid obligation collateral amount")] + InvalidObligationCollateral, + /// Obligation collateral is already below required amount + #[error("Obligation collateral is already below required amount")] + ObligationCollateralBelowRequired, + /// Obligation collateral cannot be withdrawn below required amount + #[error("Obligation collateral cannot be withdrawn below required amount")] + ObligationCollateralWithdrawBelowRequired, } impl From for ProgramError { diff --git a/token-lending/program/src/instruction.rs b/token-lending/program/src/instruction.rs index cfa934e4..437579f2 100644 --- a/token-lending/program/src/instruction.rs +++ b/token-lending/program/src/instruction.rs @@ -197,6 +197,47 @@ pub enum LendingInstruction { /// 1. `[writable]` Reserve account. /// .. `[writable]` Additional reserve accounts. AccrueReserveInterest, + + /// Deposit additional collateral to an obligation. + /// + /// 0. `[writable]` Source collateral token account, minted by deposit reserve collateral mint, + /// $authority can transfer $collateral_amount + /// 1. `[writable]` Destination deposit reserve collateral supply SPL Token account + /// 2. `[]` Deposit reserve account. + /// 3. `[writable]` Obligation + /// 4. `[writable]` Obligation token mint + /// 5. `[writable]` Obligation token output + /// 6. `[]` Lending market account. + /// 7. `[]` Derived lending market authority. + /// 8. `[]` User transfer authority ($authority). + /// 9. '[]` Token program id + DepositObligationCollateral { + /// Amount of collateral to deposit + collateral_amount: u64, + }, + + /// Withdraw excess collateral from an obligation. The loan must remain healthy. + /// + /// 0. `[writable]` Source withdraw reserve collateral supply SPL Token account + /// 1. `[writable]` Destination collateral token account, minted by withdraw reserve + /// collateral mint. $authority can transfer $collateral_amount + /// 2. `[]` Withdraw reserve account. + /// 3. `[]` Borrow reserve account. + /// 4. `[writable]` Obligation + /// 5. `[writable]` Obligation token mint + /// 6. `[writable]` Obligation token input + /// 7. `[]` Lending market account. + /// 8. `[]` Derived lending market authority. + /// 9. `[]` User transfer authority ($authority). + /// 10 `[]` Dex market + /// 11 `[]` Dex market order book side + /// 12 `[]` Temporary memory + /// 13 `[]` Clock sysvar + /// 14 '[]` Token program id + WithdrawObligationCollateral { + /// Amount of collateral to withdraw + collateral_amount: u64, + }, } impl LendingInstruction { @@ -266,6 +307,14 @@ impl LendingInstruction { Self::LiquidateObligation { liquidity_amount } } 8 => Self::AccrueReserveInterest, + 9 => { + let (collateral_amount, _rest) = Self::unpack_u64(rest)?; + Self::DepositObligationCollateral { collateral_amount } + } + 10 => { + let (collateral_amount, _rest) = Self::unpack_u64(rest)?; + Self::WithdrawObligationCollateral { collateral_amount } + } _ => return Err(LendingError::InstructionUnpackError.into()), }) } @@ -376,6 +425,14 @@ impl LendingInstruction { Self::AccrueReserveInterest => { buf.push(8); } + Self::DepositObligationCollateral { collateral_amount } => { + buf.push(9); + buf.extend_from_slice(&collateral_amount.to_le_bytes()); + } + Self::WithdrawObligationCollateral { collateral_amount } => { + buf.push(10); + buf.extend_from_slice(&collateral_amount.to_le_bytes()); + } } buf } @@ -712,3 +769,78 @@ pub fn accrue_reserve_interest(program_id: Pubkey, reserve_pubkeys: Vec) data: LendingInstruction::AccrueReserveInterest.pack(), } } + +/// Creates a 'DepositObligationCollateral' instruction. +#[allow(clippy::too_many_arguments)] +pub fn deposit_obligation_collateral( + program_id: Pubkey, + collateral_amount: u64, + source_collateral_pubkey: Pubkey, + destination_collateral_pubkey: Pubkey, + deposit_reserve_pubkey: Pubkey, + obligation_pubkey: Pubkey, + obligation_mint_pubkey: Pubkey, + obligation_output_pubkey: Pubkey, + lending_market_pubkey: Pubkey, + lending_market_authority_pubkey: Pubkey, + user_transfer_authority_pubkey: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(source_collateral_pubkey, false), + AccountMeta::new(destination_collateral_pubkey, false), + AccountMeta::new_readonly(deposit_reserve_pubkey, false), + AccountMeta::new(obligation_pubkey, false), + AccountMeta::new(obligation_mint_pubkey, false), + AccountMeta::new(obligation_output_pubkey, false), + AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new_readonly(lending_market_authority_pubkey, false), + AccountMeta::new_readonly(user_transfer_authority_pubkey, true), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::DepositObligationCollateral { collateral_amount }.pack(), + } +} + +/// Creates an 'WithdrawObligationCollateral' instruction. +#[allow(clippy::too_many_arguments)] +pub fn withdraw_obligation_collateral( + program_id: Pubkey, + collateral_amount: u64, + source_collateral_pubkey: Pubkey, + destination_collateral_pubkey: Pubkey, + withdraw_reserve_pubkey: Pubkey, + borrow_reserve_pubkey: Pubkey, + obligation_pubkey: Pubkey, + obligation_mint_pubkey: Pubkey, + obligation_input_pubkey: Pubkey, + lending_market_pubkey: Pubkey, + lending_market_authority_pubkey: Pubkey, + user_transfer_authority_pubkey: Pubkey, + dex_market_pubkey: Pubkey, + dex_market_order_book_side_pubkey: Pubkey, + memory_pubkey: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(source_collateral_pubkey, false), + AccountMeta::new(destination_collateral_pubkey, false), + AccountMeta::new_readonly(withdraw_reserve_pubkey, false), + AccountMeta::new_readonly(borrow_reserve_pubkey, false), + AccountMeta::new(obligation_pubkey, false), + AccountMeta::new(obligation_mint_pubkey, false), + AccountMeta::new(obligation_input_pubkey, false), + AccountMeta::new_readonly(lending_market_pubkey, false), + AccountMeta::new_readonly(lending_market_authority_pubkey, false), + AccountMeta::new_readonly(user_transfer_authority_pubkey, true), + AccountMeta::new_readonly(dex_market_pubkey, false), + AccountMeta::new_readonly(dex_market_order_book_side_pubkey, false), + AccountMeta::new_readonly(memory_pubkey, false), + AccountMeta::new_readonly(sysvar::clock::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::WithdrawObligationCollateral { collateral_amount }.pack(), + } +} diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index a31a501c..f91d28ef 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -76,6 +76,14 @@ pub fn process_instruction( msg!("Instruction: Accrue Interest"); process_accrue_interest(program_id, accounts) } + LendingInstruction::DepositObligationCollateral { collateral_amount } => { + msg!("Instruction: Deposit Obligation Collateral"); + process_deposit_obligation_collateral(program_id, collateral_amount, accounts) + } + LendingInstruction::WithdrawObligationCollateral { collateral_amount } => { + msg!("Instruction: Withdraw Obligation Collateral"); + process_withdraw_obligation_collateral(program_id, collateral_amount, accounts) + } } } @@ -1178,6 +1186,317 @@ fn process_accrue_interest(program_id: &Pubkey, accounts: &[AccountInfo]) -> Pro Ok(()) } +#[inline(never)] // avoid stack frame limit +fn process_deposit_obligation_collateral( + program_id: &Pubkey, + collateral_amount: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + if collateral_amount == 0 { + return Err(LendingError::InvalidAmount.into()); + } + + let account_info_iter = &mut accounts.iter(); + let source_collateral_info = next_account_info(account_info_iter)?; + let destination_collateral_info = next_account_info(account_info_iter)?; + let deposit_reserve_info = next_account_info(account_info_iter)?; + let obligation_info = next_account_info(account_info_iter)?; + let obligation_token_mint_info = next_account_info(account_info_iter)?; + let obligation_token_output_info = next_account_info(account_info_iter)?; + let lending_market_info = next_account_info(account_info_iter)?; + let lending_market_authority_info = next_account_info(account_info_iter)?; + let user_transfer_authority_info = next_account_info(account_info_iter)?; + let token_program_id = next_account_info(account_info_iter)?; + + let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + if lending_market_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + if &lending_market.token_program_id != token_program_id.key { + return Err(LendingError::InvalidTokenProgram.into()); + } + + let deposit_reserve = Reserve::unpack(&deposit_reserve_info.data.borrow())?; + if deposit_reserve_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + if &deposit_reserve.lending_market != lending_market_info.key { + msg!("Invalid reserve lending market account"); + return Err(LendingError::InvalidAccountInput.into()); + } + if deposit_reserve.config.loan_to_value_ratio == 0 { + return Err(LendingError::ReserveCollateralDisabled.into()); + } + if &deposit_reserve.collateral.supply_pubkey != destination_collateral_info.key { + msg!("Invalid deposit reserve collateral supply account input"); + return Err(LendingError::InvalidAccountInput.into()); + } + if &deposit_reserve.collateral.supply_pubkey == source_collateral_info.key { + msg!("Cannot use deposit reserve collateral supply as source collateral account input"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let mut obligation = Obligation::unpack(&obligation_info.data.borrow())?; + if obligation_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + + if &obligation.collateral_reserve != deposit_reserve_info.key { + msg!("Invalid deposit reserve account"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if &obligation.token_mint != obligation_token_mint_info.key { + msg!("Obligation token mint input doesn't match existing obligation token mint"); + return Err(LendingError::InvalidTokenMint.into()); + } + + let obligation_token_output = Token::unpack(&obligation_token_output_info.data.borrow())?; + if obligation_token_output_info.owner != token_program_id.key { + return Err(LendingError::InvalidTokenOwner.into()); + } + if &obligation_token_output.mint != obligation_token_mint_info.key { + return Err(LendingError::InvalidTokenMint.into()); + } + + obligation.deposited_collateral_tokens = obligation + .deposited_collateral_tokens + .checked_add(collateral_amount) + .ok_or(LendingError::MathOverflow)?; + + Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; + + let authority_signer_seeds = &[ + lending_market_info.key.as_ref(), + &[lending_market.bump_seed], + ]; + let lending_market_authority_pubkey = + Pubkey::create_program_address(authority_signer_seeds, program_id)?; + if lending_market_authority_info.key != &lending_market_authority_pubkey { + return Err(LendingError::InvalidMarketAuthority.into()); + } + + // deposit collateral + spl_token_transfer(TokenTransferParams { + source: source_collateral_info.clone(), + destination: destination_collateral_info.clone(), + amount: collateral_amount, + authority: user_transfer_authority_info.clone(), + authority_signer_seeds: &[], + token_program: token_program_id.clone(), + })?; + + // mint obligation tokens to output account + spl_token_mint_to(TokenMintToParams { + mint: obligation_token_mint_info.clone(), + destination: obligation_token_output_info.clone(), + amount: collateral_amount, + authority: lending_market_authority_info.clone(), + authority_signer_seeds, + token_program: token_program_id.clone(), + })?; + + Ok(()) +} + +#[inline(never)] // avoid stack frame limit +fn process_withdraw_obligation_collateral( + program_id: &Pubkey, + collateral_amount: u64, + accounts: &[AccountInfo], +) -> ProgramResult { + if collateral_amount == 0 { + return Err(LendingError::InvalidAmount.into()); + } + + let account_info_iter = &mut accounts.iter(); + let source_collateral_info = next_account_info(account_info_iter)?; + let destination_collateral_info = next_account_info(account_info_iter)?; + let withdraw_reserve_info = next_account_info(account_info_iter)?; + let borrow_reserve_info = next_account_info(account_info_iter)?; + let obligation_info = next_account_info(account_info_iter)?; + let obligation_token_mint_info = next_account_info(account_info_iter)?; + let obligation_token_input_info = next_account_info(account_info_iter)?; + let lending_market_info = next_account_info(account_info_iter)?; + let lending_market_authority_info = next_account_info(account_info_iter)?; + let user_transfer_authority_info = next_account_info(account_info_iter)?; + let dex_market_info = next_account_info(account_info_iter)?; + let dex_market_orders_info = next_account_info(account_info_iter)?; + let memory = next_account_info(account_info_iter)?; + let clock = &Clock::from_account_info(next_account_info(account_info_iter)?)?; + let token_program_id = next_account_info(account_info_iter)?; + + // Ensure memory is owned by this program so that we don't have to zero it out + if memory.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + + let lending_market = LendingMarket::unpack(&lending_market_info.data.borrow())?; + if lending_market_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + if &lending_market.token_program_id != token_program_id.key { + return Err(LendingError::InvalidTokenProgram.into()); + } + + let withdraw_reserve = Reserve::unpack(&withdraw_reserve_info.data.borrow())?; + if withdraw_reserve_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + if &withdraw_reserve.lending_market != lending_market_info.key { + msg!("Invalid reserve lending market account"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let borrow_reserve = Reserve::unpack(&borrow_reserve_info.data.borrow())?; + if borrow_reserve_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + if borrow_reserve.lending_market != withdraw_reserve.lending_market { + return Err(LendingError::LendingMarketMismatch.into()); + } + + if withdraw_reserve.config.loan_to_value_ratio == 0 { + return Err(LendingError::ReserveCollateralDisabled.into()); + } + + if withdraw_reserve_info.key == borrow_reserve_info.key { + return Err(LendingError::DuplicateReserve.into()); + } + + if &withdraw_reserve.collateral.supply_pubkey != source_collateral_info.key { + msg!("Invalid withdraw reserve collateral supply account input"); + return Err(LendingError::InvalidAccountInput.into()); + } + + if &withdraw_reserve.collateral.supply_pubkey == destination_collateral_info.key { + msg!( + "Cannot use withdraw reserve collateral supply as destination collateral account input" + ); + return Err(LendingError::InvalidAccountInput.into()); + } + + // TODO: handle case when neither reserve is the quote currency + if borrow_reserve.dex_market.is_none() && withdraw_reserve.dex_market.is_none() { + msg!("One reserve must have a dex market"); + return Err(LendingError::InvalidAccountInput.into()); + } + if let COption::Some(dex_market_pubkey) = borrow_reserve.dex_market { + if &dex_market_pubkey != dex_market_info.key { + msg!("Invalid dex market account input"); + return Err(LendingError::InvalidAccountInput.into()); + } + } + if let COption::Some(dex_market_pubkey) = withdraw_reserve.dex_market { + if &dex_market_pubkey != dex_market_info.key { + msg!("Invalid dex market account input"); + return Err(LendingError::InvalidAccountInput.into()); + } + } + + let mut obligation = Obligation::unpack(&obligation_info.data.borrow())?; + if obligation_info.owner != program_id { + return Err(LendingError::InvalidAccountOwner.into()); + } + + if &obligation.collateral_reserve != withdraw_reserve_info.key { + msg!("Invalid withdraw reserve account"); + return Err(LendingError::InvalidAccountInput.into()); + } + + let obligation_token_mint = unpack_mint(&obligation_token_mint_info.data.borrow())?; + if &obligation.token_mint != obligation_token_mint_info.key { + msg!("Obligation token mint input doesn't match existing obligation token mint"); + return Err(LendingError::InvalidTokenMint.into()); + } + + let obligation_token_input = Token::unpack(&obligation_token_input_info.data.borrow())?; + if obligation_token_input_info.owner != token_program_id.key { + return Err(LendingError::InvalidTokenOwner.into()); + } + if &obligation_token_input.mint != obligation_token_mint_info.key { + return Err(LendingError::InvalidTokenMint.into()); + } + + // accrue interest and update rates + assert_last_update_slot(&borrow_reserve, clock.slot)?; + assert_last_update_slot(&withdraw_reserve, clock.slot)?; + + obligation.accrue_interest(borrow_reserve.cumulative_borrow_rate_wads)?; + + let obligation_collateral_amount = obligation.deposited_collateral_tokens; + if obligation_collateral_amount == 0 { + return Err(LendingError::ObligationEmpty.into()); + } + if obligation_collateral_amount < collateral_amount { + return Err(LendingError::InvalidObligationCollateral.into()); + } + + let trade_simulator = TradeSimulator::new( + dex_market_info, + dex_market_orders_info, + memory, + &lending_market.quote_token_mint, + &borrow_reserve.liquidity.mint_pubkey, + &withdraw_reserve.liquidity.mint_pubkey, + )?; + + let required_collateral = withdraw_reserve.required_collateral_for_borrow( + obligation.borrowed_liquidity_wads.try_ceil_u64()?, + &borrow_reserve.liquidity.mint_pubkey, + trade_simulator, + )?; + if obligation_collateral_amount < required_collateral { + return Err(LendingError::ObligationCollateralBelowRequired.into()); + } + + let remaining_collateral = obligation_collateral_amount + .checked_sub(collateral_amount) + .ok_or(LendingError::MathOverflow)?; + if remaining_collateral < required_collateral { + return Err(LendingError::ObligationCollateralWithdrawBelowRequired.into()); + } + + let obligation_token_amount = obligation + .collateral_to_obligation_token_amount(collateral_amount, obligation_token_mint.supply)?; + + obligation.deposited_collateral_tokens = remaining_collateral; + + Obligation::pack(obligation, &mut obligation_info.data.borrow_mut())?; + + let authority_signer_seeds = &[ + lending_market_info.key.as_ref(), + &[lending_market.bump_seed], + ]; + let lending_market_authority_pubkey = + Pubkey::create_program_address(authority_signer_seeds, program_id)?; + if lending_market_authority_info.key != &lending_market_authority_pubkey { + return Err(LendingError::InvalidMarketAuthority.into()); + } + + // burn obligation tokens + spl_token_burn(TokenBurnParams { + mint: obligation_token_mint_info.clone(), + source: obligation_token_input_info.clone(), + amount: obligation_token_amount, + authority: user_transfer_authority_info.clone(), + authority_signer_seeds: &[], + token_program: token_program_id.clone(), + })?; + + // withdraw collateral + spl_token_transfer(TokenTransferParams { + source: source_collateral_info.clone(), + destination: destination_collateral_info.clone(), + amount: collateral_amount, + authority: lending_market_authority_info.clone(), + authority_signer_seeds, + token_program: token_program_id.clone(), + })?; + + Ok(()) +} + fn assert_rent_exempt(rent: &Rent, account_info: &AccountInfo) -> ProgramResult { if !rent.is_exempt(account_info.lamports(), account_info.data_len()) { msg!(&rent.minimum_balance(account_info.data_len()).to_string()); diff --git a/token-lending/program/src/state/obligation.rs b/token-lending/program/src/state/obligation.rs index aee04385..8da002f7 100644 --- a/token-lending/program/src/state/obligation.rs +++ b/token-lending/program/src/state/obligation.rs @@ -83,6 +83,18 @@ impl Obligation { loan.try_div(collateral_value) } + /// Amount of obligation tokens for given collateral + pub fn collateral_to_obligation_token_amount( + &self, + collateral_amount: u64, + obligation_token_supply: u64, + ) -> Result { + let withdraw_pct = + Decimal::from(collateral_amount).try_div(self.deposited_collateral_tokens)?; + let token_amount: Decimal = withdraw_pct.try_mul(obligation_token_supply)?; + token_amount.try_floor_u64() + } + /// Accrue interest pub fn accrue_interest(&mut self, cumulative_borrow_rate: Decimal) -> ProgramResult { if cumulative_borrow_rate < self.cumulative_borrow_rate_wads { @@ -130,12 +142,10 @@ impl Obligation { withdraw_amount.try_floor_u64()? }; - let obligation_token_amount = { - let withdraw_pct = Decimal::from(collateral_withdraw_amount) - .try_div(self.deposited_collateral_tokens)?; - let token_amount: Decimal = withdraw_pct.try_mul(obligation_token_supply)?; - token_amount.try_floor_u64()? - }; + let obligation_token_amount = self.collateral_to_obligation_token_amount( + collateral_withdraw_amount, + obligation_token_supply, + )?; self.borrowed_liquidity_wads = self.borrowed_liquidity_wads.try_sub(decimal_repay_amount)?; diff --git a/token-lending/program/tests/deposit_obligation_collateral.rs b/token-lending/program/tests/deposit_obligation_collateral.rs new file mode 100644 index 00000000..a18ed416 --- /dev/null +++ b/token-lending/program/tests/deposit_obligation_collateral.rs @@ -0,0 +1,151 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use helpers::*; +use solana_program_test::*; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; +use spl_token::instruction::approve; +use spl_token_lending::{ + instruction::deposit_obligation_collateral, math::Decimal, processor::process_instruction, + state::INITIAL_COLLATERAL_RATIO, +}; + +const LAMPORTS_TO_SOL: u64 = 1_000_000_000; +const FRACTIONAL_TO_USDC: u64 = 1_000_000; + +#[tokio::test] +async fn test_success() { + let mut test = ProgramTest::new( + "spl_token_lending", + spl_token_lending::id(), + processor!(process_instruction), + ); + + // limit to track compute unit increase + test.set_bpf_compute_max_units(25_000); + + const INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS: u64 = 100 * LAMPORTS_TO_SOL; + const INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL: u64 = 100 * FRACTIONAL_TO_USDC; + + const DEPOSIT_COLLATERAL: u64 = 1 * LAMPORTS_TO_SOL; + + const OBLIGATION_LOAN: u64 = 10 * FRACTIONAL_TO_USDC; + const OBLIGATION_COLLATERAL: u64 = 10 * LAMPORTS_TO_SOL * INITIAL_COLLATERAL_RATIO; + + let user_accounts_owner = Keypair::new(); + let user_transfer_authority = Keypair::new(); + let usdc_mint = add_usdc_mint(&mut test); + let lending_market = add_lending_market(&mut test, usdc_mint.pubkey); + + let sol_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + liquidity_amount: INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS, + liquidity_mint_decimals: 9, + liquidity_mint_pubkey: spl_token::native_mint::id(), + collateral_amount: OBLIGATION_COLLATERAL, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let usdc_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + initial_borrow_rate: 1, + liquidity_amount: INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL, + liquidity_mint_pubkey: usdc_mint.pubkey, + liquidity_mint_decimals: usdc_mint.decimals, + borrow_amount: OBLIGATION_LOAN * 101 / 100, + user_liquidity_amount: OBLIGATION_LOAN, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let obligation = add_obligation( + &mut test, + &user_accounts_owner, + &lending_market, + AddObligationArgs { + borrow_reserve: &usdc_reserve, + collateral_reserve: &sol_reserve, + collateral_amount: OBLIGATION_COLLATERAL, + borrowed_liquidity_wads: Decimal::from(OBLIGATION_LOAN), + }, + ); + + let (mut banks_client, payer, recent_blockhash) = test.start().await; + + let initial_collateral_supply_balance = + get_token_balance(&mut banks_client, sol_reserve.collateral_supply).await; + let initial_user_collateral_balance = + get_token_balance(&mut banks_client, sol_reserve.user_collateral_account).await; + let initial_obligation_token_balance = + get_token_balance(&mut banks_client, obligation.token_account).await; + + let mut transaction = Transaction::new_with_payer( + &[ + approve( + &spl_token::id(), + &sol_reserve.user_collateral_account, + &user_transfer_authority.pubkey(), + &user_accounts_owner.pubkey(), + &[], + DEPOSIT_COLLATERAL, + ) + .unwrap(), + deposit_obligation_collateral( + spl_token_lending::id(), + DEPOSIT_COLLATERAL, + sol_reserve.user_collateral_account, + sol_reserve.collateral_supply, + sol_reserve.pubkey, + obligation.pubkey, + obligation.token_mint, + obligation.token_account, + lending_market.pubkey, + lending_market.authority, + user_transfer_authority.pubkey(), + ), + ], + Some(&payer.pubkey()), + ); + + transaction.sign( + &[&payer, &user_accounts_owner, &user_transfer_authority], + recent_blockhash, + ); + assert!(banks_client.process_transaction(transaction).await.is_ok()); + + // check that collateral tokens were transferred + let collateral_supply_balance = + get_token_balance(&mut banks_client, sol_reserve.collateral_supply).await; + assert_eq!( + collateral_supply_balance, + initial_collateral_supply_balance + DEPOSIT_COLLATERAL + ); + let user_collateral_balance = + get_token_balance(&mut banks_client, sol_reserve.user_collateral_account).await; + assert_eq!( + user_collateral_balance, + initial_user_collateral_balance - DEPOSIT_COLLATERAL + ); + + // check that obligation tokens were minted + let obligation_token_balance = + get_token_balance(&mut banks_client, obligation.token_account).await; + assert_eq!( + obligation_token_balance, + initial_obligation_token_balance + DEPOSIT_COLLATERAL + ); +} diff --git a/token-lending/program/tests/withdraw_obligation_collateral.rs b/token-lending/program/tests/withdraw_obligation_collateral.rs new file mode 100644 index 00000000..3262b50e --- /dev/null +++ b/token-lending/program/tests/withdraw_obligation_collateral.rs @@ -0,0 +1,336 @@ +#![cfg(feature = "test-bpf")] + +use solana_program_test::*; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + system_instruction::create_account, + transaction::{Transaction, TransactionError}, +}; + +use helpers::*; +use spl_token::instruction::approve; +use spl_token_lending::{ + error::LendingError, + instruction::withdraw_obligation_collateral, + math::Decimal, + processor::process_instruction, + state::{INITIAL_COLLATERAL_RATIO, SLOTS_PER_YEAR}, +}; + +mod helpers; + +const LAMPORTS_TO_SOL: u64 = 1_000_000_000; +const FRACTIONAL_TO_USDC: u64 = 1_000_000; + +#[tokio::test] +async fn test_success() { + let mut test = ProgramTest::new( + "spl_token_lending", + spl_token_lending::id(), + processor!(process_instruction), + ); + + // limit to track compute unit increase + test.set_bpf_compute_max_units(84_000); + + const INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS: u64 = 100 * LAMPORTS_TO_SOL; + const INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL: u64 = 100 * FRACTIONAL_TO_USDC; + + const OBLIGATION_LOAN: u64 = 10 * FRACTIONAL_TO_USDC; + const OBLIGATION_COLLATERAL: u64 = 10 * LAMPORTS_TO_SOL * INITIAL_COLLATERAL_RATIO; + + // from Reserve::required_collateral_for_borrow + const REQUIRED_COLLATERAL: u64 = 45_929_968_168; + const WITHDRAW_COLLATERAL: u64 = OBLIGATION_COLLATERAL - REQUIRED_COLLATERAL; + + let user_accounts_owner = Keypair::new(); + let memory_keypair = Keypair::new(); + let user_transfer_authority = Keypair::new(); + let sol_usdc_dex_market = TestDexMarket::setup(&mut test, TestDexMarketPair::SOL_USDC); + let usdc_mint = add_usdc_mint(&mut test); + let lending_market = add_lending_market(&mut test, usdc_mint.pubkey); + + let sol_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + slots_elapsed: SLOTS_PER_YEAR, + liquidity_amount: INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS, + liquidity_mint_decimals: 9, + liquidity_mint_pubkey: spl_token::native_mint::id(), + dex_market_pubkey: Some(sol_usdc_dex_market.pubkey), + collateral_amount: OBLIGATION_COLLATERAL, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let usdc_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + initial_borrow_rate: 1, + liquidity_amount: INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL, + liquidity_mint_pubkey: usdc_mint.pubkey, + liquidity_mint_decimals: usdc_mint.decimals, + borrow_amount: OBLIGATION_LOAN * 101 / 100, + user_liquidity_amount: OBLIGATION_LOAN, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let obligation = add_obligation( + &mut test, + &user_accounts_owner, + &lending_market, + AddObligationArgs { + borrow_reserve: &usdc_reserve, + collateral_reserve: &sol_reserve, + collateral_amount: OBLIGATION_COLLATERAL, + borrowed_liquidity_wads: Decimal::from(OBLIGATION_LOAN), + }, + ); + + let (mut banks_client, payer, recent_blockhash) = test.start().await; + + let initial_collateral_supply_balance = + get_token_balance(&mut banks_client, sol_reserve.collateral_supply).await; + let initial_user_collateral_balance = + get_token_balance(&mut banks_client, sol_reserve.user_collateral_account).await; + let initial_obligation_token_balance = + get_token_balance(&mut banks_client, obligation.token_account).await; + + let mut transaction = Transaction::new_with_payer( + &[ + create_account( + &payer.pubkey(), + &memory_keypair.pubkey(), + 0, + 65548, + &spl_token_lending::id(), + ), + approve( + &spl_token::id(), + &sol_reserve.user_collateral_account, + &user_transfer_authority.pubkey(), + &user_accounts_owner.pubkey(), + &[], + OBLIGATION_LOAN, + ) + .unwrap(), + approve( + &spl_token::id(), + &obligation.token_account, + &user_transfer_authority.pubkey(), + &user_accounts_owner.pubkey(), + &[], + OBLIGATION_COLLATERAL, + ) + .unwrap(), + withdraw_obligation_collateral( + spl_token_lending::id(), + WITHDRAW_COLLATERAL, + sol_reserve.collateral_supply, + sol_reserve.user_collateral_account, + sol_reserve.pubkey, + usdc_reserve.pubkey, + obligation.pubkey, + obligation.token_mint, + obligation.token_account, + lending_market.pubkey, + lending_market.authority, + user_transfer_authority.pubkey(), + sol_usdc_dex_market.pubkey, + sol_usdc_dex_market.bids_pubkey, + memory_keypair.pubkey(), + ), + ], + Some(&payer.pubkey()), + ); + + transaction.sign( + &[ + &payer, + &memory_keypair, + &user_accounts_owner, + &user_transfer_authority, + ], + recent_blockhash, + ); + assert!(banks_client.process_transaction(transaction).await.is_ok()); + + // check that collateral tokens were transferred + let collateral_supply_balance = + get_token_balance(&mut banks_client, sol_reserve.collateral_supply).await; + assert_eq!( + collateral_supply_balance, + initial_collateral_supply_balance - WITHDRAW_COLLATERAL + ); + let user_collateral_balance = + get_token_balance(&mut banks_client, sol_reserve.user_collateral_account).await; + assert_eq!( + user_collateral_balance, + initial_user_collateral_balance + WITHDRAW_COLLATERAL + ); + + // check that obligation tokens were burned + let obligation_token_balance = + get_token_balance(&mut banks_client, obligation.token_account).await; + assert_eq!( + obligation_token_balance, + initial_obligation_token_balance - WITHDRAW_COLLATERAL + ); +} + +#[tokio::test] +async fn test_withdraw_below_required() { + let mut test = ProgramTest::new( + "spl_token_lending", + spl_token_lending::id(), + processor!(process_instruction), + ); + + // limit to track compute unit increase + test.set_bpf_compute_max_units(84_000); + + const INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS: u64 = 100 * LAMPORTS_TO_SOL; + const INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL: u64 = 100 * FRACTIONAL_TO_USDC; + + const OBLIGATION_LOAN: u64 = 10 * FRACTIONAL_TO_USDC; + const OBLIGATION_COLLATERAL: u64 = 10 * LAMPORTS_TO_SOL * INITIAL_COLLATERAL_RATIO; + + // from Reserve::required_collateral_for_borrow + const REQUIRED_COLLATERAL: u64 = 45_929_968_168; + const WITHDRAW_COLLATERAL: u64 = OBLIGATION_COLLATERAL - REQUIRED_COLLATERAL + 1; + + let user_accounts_owner = Keypair::new(); + let memory_keypair = Keypair::new(); + let user_transfer_authority = Keypair::new(); + let sol_usdc_dex_market = TestDexMarket::setup(&mut test, TestDexMarketPair::SOL_USDC); + let usdc_mint = add_usdc_mint(&mut test); + let lending_market = add_lending_market(&mut test, usdc_mint.pubkey); + + let sol_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + slots_elapsed: SLOTS_PER_YEAR, + liquidity_amount: INITIAL_SOL_RESERVE_SUPPLY_LAMPORTS, + liquidity_mint_decimals: 9, + liquidity_mint_pubkey: spl_token::native_mint::id(), + dex_market_pubkey: Some(sol_usdc_dex_market.pubkey), + collateral_amount: OBLIGATION_COLLATERAL, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let usdc_reserve = add_reserve( + &mut test, + &user_accounts_owner, + &lending_market, + AddReserveArgs { + initial_borrow_rate: 1, + liquidity_amount: INITIAL_USDC_RESERVE_SUPPLY_FRACTIONAL, + liquidity_mint_pubkey: usdc_mint.pubkey, + liquidity_mint_decimals: usdc_mint.decimals, + borrow_amount: OBLIGATION_LOAN * 101 / 100, + user_liquidity_amount: OBLIGATION_LOAN, + config: TEST_RESERVE_CONFIG, + ..AddReserveArgs::default() + }, + ); + + let obligation = add_obligation( + &mut test, + &user_accounts_owner, + &lending_market, + AddObligationArgs { + borrow_reserve: &usdc_reserve, + collateral_reserve: &sol_reserve, + collateral_amount: OBLIGATION_COLLATERAL, + borrowed_liquidity_wads: Decimal::from(OBLIGATION_LOAN), + }, + ); + + let (mut banks_client, payer, recent_blockhash) = test.start().await; + + let mut transaction = Transaction::new_with_payer( + &[ + create_account( + &payer.pubkey(), + &memory_keypair.pubkey(), + 0, + 65548, + &spl_token_lending::id(), + ), + approve( + &spl_token::id(), + &sol_reserve.user_collateral_account, + &user_transfer_authority.pubkey(), + &user_accounts_owner.pubkey(), + &[], + OBLIGATION_LOAN, + ) + .unwrap(), + approve( + &spl_token::id(), + &obligation.token_account, + &user_transfer_authority.pubkey(), + &user_accounts_owner.pubkey(), + &[], + OBLIGATION_COLLATERAL, + ) + .unwrap(), + withdraw_obligation_collateral( + spl_token_lending::id(), + WITHDRAW_COLLATERAL, + sol_reserve.collateral_supply, + sol_reserve.user_collateral_account, + sol_reserve.pubkey, + usdc_reserve.pubkey, + obligation.pubkey, + obligation.token_mint, + obligation.token_account, + lending_market.pubkey, + lending_market.authority, + user_transfer_authority.pubkey(), + sol_usdc_dex_market.pubkey, + sol_usdc_dex_market.bids_pubkey, + memory_keypair.pubkey(), + ), + ], + Some(&payer.pubkey()), + ); + + transaction.sign( + &[ + &payer, + &memory_keypair, + &user_accounts_owner, + &user_transfer_authority, + ], + recent_blockhash, + ); + + // check that transaction fails + assert_eq!( + banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(), + TransactionError::InstructionError( + 3, + InstructionError::Custom( + LendingError::ObligationCollateralWithdrawBelowRequired as u32 + ) + ) + ); +}