diff --git a/token-lending/program/src/processor.rs b/token-lending/program/src/processor.rs index 93c6bbf3734..8390619dca1 100644 --- a/token-lending/program/src/processor.rs +++ b/token-lending/program/src/processor.rs @@ -255,11 +255,13 @@ pub fn process_instruction( } LendingInstruction::ClaimReward { reward_authority_bump, + position_kind, } => { msg!("Instruction: Claim Reward"); liquidity_mining::claim_user_reward::process( program_id, reward_authority_bump, + position_kind, accounts, ) } diff --git a/token-lending/program/src/processor/liquidity_mining.rs b/token-lending/program/src/processor/liquidity_mining.rs index 8c697ebb4ea..7e27bf0bd3c 100644 --- a/token-lending/program/src/processor/liquidity_mining.rs +++ b/token-lending/program/src/processor/liquidity_mining.rs @@ -12,10 +12,10 @@ //! - [close_pool_reward] //! //! There is an ix related to migration: -//! - [upgrade_reserve] (TODO: add bpf tests) +//! - [upgrade_reserve] (has anchor integration test) //! //! There is one user ix: -//! - [claim_user_reward] (TODO: add bpf tests) +//! - [claim_user_reward] //! //! [suilend-lm]: https://github.com/solendprotocol/suilend/blob/dc53150416f352053ac3acbb320ee143409c4a5d/contracts/suilend/sources/liquidity_mining.move#L2 diff --git a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs index 49d079c2287..45dd377e619 100644 --- a/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs +++ b/token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs @@ -74,6 +74,7 @@ struct ClaimUserReward<'a, 'info> { pub(crate) fn process( program_id: &Pubkey, reward_authority_bump: u8, + position_kind: PositionKind, accounts: &[AccountInfo], ) -> ProgramResult { let clock = &Clock::get()?; @@ -89,13 +90,57 @@ pub(crate) fn process( // 1. - let position_kind = accounts.obligation.find_position_kind(reserve_key)?; + let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - let Some(user_reward_manager) = accounts + if let Some(user_reward_manager) = accounts .obligation .user_reward_managers .find_mut(reserve_key, position_kind) - else { + { + msg!( + "Found user reward manager that was last updated at {} and has {}/{} shares", + user_reward_manager.last_update_time_secs, + user_reward_manager.share, + pool_reward_manager.total_shares + ); + + // 2. + + let total_reward_amount = user_reward_manager.claim_rewards( + pool_reward_manager, + *accounts.reward_token_vault_info.key, + clock, + )?; + + // 3. + + if total_reward_amount > 0 { + spl_token_transfer(TokenTransferParams { + source: accounts.reward_token_vault_info.clone(), + destination: accounts.obligation_owner_token_account_info.clone(), + amount: total_reward_amount, + authority: accounts.reward_authority_info.clone(), + authority_signer_seeds: &[ + reward_vault_authority_seeds( + accounts.lending_market_info.key, + &accounts.reserve.key(), + accounts.reward_mint_info.key, + ) + .as_slice(), + &[&[reward_authority_bump]], + ] + .concat(), + token_program: accounts.token_program_info.clone(), + })?; + } + } else { + let expected_position_kind = accounts.obligation.find_position_kind(reserve_key)?; + + if expected_position_kind != position_kind { + msg!("Obligation does not have {:?} for reserve", position_kind); + return Err(LendingError::InvalidAccountInput.into()); + } + // We've checked that the obligation associates this reserve but it's // not in the user reward managers yet. // This means that the obligation hasn't been migrated to track the @@ -103,29 +148,27 @@ pub(crate) fn process( // // We'll upgrade it here. - let reserve_key = accounts.reserve.key(); - - let (pool_reward_manager, migrated_share) = match position_kind { - PositionKind::Borrow => { - let share = accounts - .obligation - .find_liquidity_in_borrows(reserve_key)? - .0 - .liability_shares()?; - - (&mut accounts.reserve.borrows_pool_reward_manager, share) - } + let migrated_share = match position_kind { + PositionKind::Borrow => accounts + .obligation + .find_liquidity_in_borrows(reserve_key)? + .0 + .liability_shares()?, PositionKind::Deposit => { - let share = accounts + accounts .obligation .find_collateral_in_deposits(reserve_key)? .0 - .deposited_amount; - - (&mut accounts.reserve.deposits_pool_reward_manager, share) + .deposited_amount } }; + msg!( + "Migrating obligation to track pool reward manager with share of {}/{}", + migrated_share, + pool_reward_manager.total_shares + ); + accounts.obligation.user_reward_managers.set_share( reserve_key, position_kind, @@ -133,46 +176,8 @@ pub(crate) fn process( migrated_share, clock, )?; - - realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; - Obligation::pack( - *accounts.obligation, - &mut accounts.obligation_info.data.borrow_mut(), - )?; - - return Ok(()); }; - let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind); - - // 2. - - let total_reward_amount = user_reward_manager.claim_rewards( - pool_reward_manager, - *accounts.reward_token_vault_info.key, - clock, - )?; - - // 3. - - spl_token_transfer(TokenTransferParams { - source: accounts.reward_token_vault_info.clone(), - destination: accounts.obligation_owner_token_account_info.clone(), - amount: total_reward_amount, - authority: accounts.reward_authority_info.clone(), - authority_signer_seeds: &[ - reward_vault_authority_seeds( - accounts.lending_market_info.key, - &accounts.reserve.key(), - accounts.reward_mint_info.key, - ) - .as_slice(), - &[&[reward_authority_bump]], - ] - .concat(), - token_program: accounts.token_program_info.clone(), - })?; - // 4. realloc_obligation_if_necessary(&accounts.obligation, accounts.obligation_info)?; diff --git a/token-lending/program/tests/add_pool_reward.rs b/token-lending/program/tests/add_pool_reward.rs index 3e9f6f8351b..09407066fb4 100644 --- a/token-lending/program/tests/add_pool_reward.rs +++ b/token-lending/program/tests/add_pool_reward.rs @@ -54,7 +54,7 @@ async fn test_(position_kind: PositionKind) { let obligation = lending_market .init_obligation(&mut test, Keypair::new(), &user) .await - .expect("This should succeed"); + .expect("Should init obligation"); let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; @@ -62,11 +62,7 @@ async fn test_(position_kind: PositionKind) { total_shares: 0, last_update_time_secs: current_time as _, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { id: PoolRewardId(1), @@ -102,7 +98,7 @@ async fn test_(position_kind: PositionKind) { deposit_amount, ) .await - .expect("This should succeed"); + .expect("Should deposit $USDC"); deposit_amount } @@ -124,7 +120,7 @@ async fn test_(position_kind: PositionKind) { 420_000_000, ) .await - .expect("This should succeed"); + .expect("Should borrow $wSOL"); lending_market .borrow_obligation_liquidity( @@ -136,7 +132,7 @@ async fn test_(position_kind: PositionKind) { 690, ) .await - .unwrap(); + .expect("Should borrow $USDC"); 690 } diff --git a/token-lending/program/tests/cancel_pool_reward.rs b/token-lending/program/tests/cancel_pool_reward.rs index 7bd5b1caca3..fa09068aca4 100644 --- a/token-lending/program/tests/cancel_pool_reward.rs +++ b/token-lending/program/tests/cancel_pool_reward.rs @@ -31,13 +31,12 @@ async fn test_cancel_pool_reward_for_borrow() { async fn test_(position_kind: PositionKind) { let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = setup_world(&test_reserve_config(), &test_reserve_config()).await; - let mut clock = test.get_clock().await; let reward_mint = test.create_mint_as_test_authority().await; let reward_vault = Keypair::new(); let duration_secs = 3_600; let total_rewards = 1_000_000; - let initial_time = clock.unix_timestamp as u64; + let initial_time = test.get_clock().await.unix_timestamp as u64; let reward = LiqMiningReward { mint: reward_mint, vault: reward_vault.insecure_clone(), @@ -63,9 +62,9 @@ async fn test_(position_kind: PositionKind) { ) .await; - clock.unix_timestamp += duration_secs as i64 / 2; - test.context.set_sysvar(&clock); - let time_when_cancelling = clock.unix_timestamp as u64; + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; let pool_reward_index = 0; lending_market @@ -100,13 +99,9 @@ async fn test_(position_kind: PositionKind) { let expected_reward_manager = Box::new(PoolRewardManager { total_shares: 0, - last_update_time_secs: time_when_cancelling as _, + last_update_time_secs: current_time, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { id: PoolRewardId(1), diff --git a/token-lending/program/tests/claim_pool_reward.rs b/token-lending/program/tests/claim_pool_reward.rs new file mode 100644 index 00000000000..f48322796df --- /dev/null +++ b/token-lending/program/tests/claim_pool_reward.rs @@ -0,0 +1,526 @@ +#![cfg(feature = "test-bpf")] + +mod helpers; + +use std::collections::HashSet; + +use helpers::solend_program_test::{ + setup_world, BalanceChecker, LiqMiningReward, TokenAccount, TokenBalanceChange, +}; +use helpers::test_reserve_config; + +use pretty_assertions::assert_eq; +use solana_program_test::*; +use solana_sdk::account::Account; +use solana_sdk::instruction::InstructionError; +use solana_sdk::signature::{Keypair, Signer}; +use solana_sdk::transaction::TransactionError; +use solend_program::{ + math::Decimal, + state::{PoolRewardId, PoolRewardManager, PositionKind, Reserve, UserRewardManager}, +}; +use solend_sdk::error::LendingError; +use solend_sdk::math::TryMul; +use solend_sdk::state::{Obligation, PoolReward, PoolRewardSlot, UserReward}; + +#[tokio::test] +async fn test_claim_pool_reward_for_deposit() { + test_(PositionKind::Deposit).await; +} + +#[tokio::test] +async fn test_claim_pool_reward_for_borrow() { + test_(PositionKind::Borrow).await; +} + +async fn test_(position_kind: PositionKind) { + let (mut test, lending_market, usdc_reserve, wsol_reserve, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let current_time = test.get_clock().await.unix_timestamp as u64; + let initial_time = current_time; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + position_kind, + current_time, + current_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + let expected_share = match position_kind { + PositionKind::Deposit => { + let deposit_amount = 1_000_000; + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + deposit_amount, + ) + .await + .expect("Should deposit $USDC"); + + deposit_amount + } + PositionKind::Borrow => { + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &wsol_reserve, + &obligation, + &user, + 420_000_000, + ) + .await + .expect("Should deposit $wSOL"); + + lending_market + .borrow_obligation_liquidity( + &mut test, + &usdc_reserve, + &obligation, + &user, + None, + 690, + ) + .await + .expect("Should borrow $USDC"); + + 690 + } + }; + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_token_account(&reward.mint, &mut test).await; + + let balance_checker = + BalanceChecker::start(&mut test, &[&TokenAccount(reward.vault.pubkey()), &user]).await; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + ) + .await + .expect("Should claim reward"); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + + let diff = (total_rewards as i128) / 2 + - match position_kind { + PositionKind::Deposit => 0, + PositionKind::Borrow => 1, // integer division rounds down + }; + let expected_balance_changes = HashSet::from([ + TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + diff, + }, + TokenBalanceChange { + token_account: reward.vault.pubkey(), + mint: reward.mint, + diff: -diff, + }, + ]); + assert_eq!(balance_changes, expected_balance_changes); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + let cumulative_rewards_per_share = match position_kind { + PositionKind::Deposit => Decimal::from_scaled_val(500000000000000000), + PositionKind::Borrow => Decimal::from_scaled_val(724637681159420289855), + }; + + let expected_reward_manager = PoolRewardManager { + total_shares: expected_share, + last_update_time_secs: current_time, + pool_rewards: { + let mut og = PoolRewardManager::default().pool_rewards; + + og[0] = PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 1, + cumulative_rewards_per_share, + })); + + og + }, + }; + + assert_eq!( + usdc_reserve_post.account.pool_reward_manager(position_kind), + &expected_reward_manager + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + let earned_rewards = match position_kind { + PositionKind::Deposit => { + // on deposit there's no division involved and so it ends up being + // nice whole number + Decimal::zero() + } + PositionKind::Borrow => { + // on borrow we have some precision loss and so the one extra + // _almost_ token stays in the user's account + Decimal::from_scaled_val(999999999999999950) + } + }; + // we don't withdraw fractions of a token but keep them around for future claims + assert_eq!(earned_rewards.try_floor_u64().unwrap(), 0); + + assert_eq!( + obligation_post.account.user_reward_managers.last().unwrap(), + &UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind, + share: expected_share, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards, + cumulative_rewards_per_share + }], + } + ); + + // move time forward so that all rewards can be claimed + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as _) + .await; + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + position_kind, + ) + .await + .expect("Should claim reward"); + + // reserve should have no user reward managers + + let usdc_reserve_final = test.load_account::(usdc_reserve.pubkey).await; + let pool_reward_manager = usdc_reserve_final + .account + .pool_reward_manager(position_kind); + + assert_eq!(pool_reward_manager.last_update_time_secs, current_time); + + assert_eq!( + pool_reward_manager.pool_rewards[0], + PoolRewardSlot::Occupied(Box::new(PoolReward { + id: PoolRewardId(1), + vault: reward_vault.pubkey(), + start_time_secs: initial_time, + duration_secs, + total_rewards, + num_user_reward_managers: 0, + cumulative_rewards_per_share: cumulative_rewards_per_share + .try_mul(Decimal::from(2u64)) + .unwrap() + })) + ); + + // obligation should no longer track this reward + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final + .account + .user_reward_managers + .last() + .unwrap() + .rewards, + vec![], + ); +} + +#[tokio::test] +async fn test_cannot_claim_into_wrong_destination() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + // let's use a token account of a wrong user + lending_market_owner + .create_token_account(&reward.mint, &mut test) + .await; + + let err = lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &lending_market_owner, // ! wrong + &reward, + PositionKind::Deposit, + ) + .await + .expect_err("Cannot steal user reward"); + + assert_eq!( + err.unwrap(), + TransactionError::InstructionError( + 1, + InstructionError::Custom(LendingError::InvalidAccountInput as _) + ) + ); +} + +#[tokio::test] +async fn test_migrate_obligation() { + let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, mut user) = + setup_world(&test_reserve_config(), &test_reserve_config()).await; + + let obligation = lending_market + .init_obligation(&mut test, Keypair::new(), &user) + .await + .expect("Should init obligation"); + + lending_market + .deposit_reserve_liquidity_and_obligation_collateral( + &mut test, + &usdc_reserve, + &obligation, + &user, + 1, + ) + .await + .expect("Should deposit $USDC"); + + { + // The call above set up the obligation with a user reward manager. + // We'll now truncate the liq. mining data to simulate an obligation in + // the old format. + // However, that will leave the reserve in an invalid state as it will + // have already the user shares set up. + // That's ok, let's just ignore that in this test. + + let mut new_raw_obligation = Account { + data: vec![0; Obligation::MIN_LEN], + ..test + .context + .banks_client + .get_account(obligation.pubkey) + .await + .expect("Should access obligation account") + .expect("Obligation account should exist") + }; + + Obligation::pack( + { + let mut obligation = test.load_obligation(obligation.pubkey).await; + obligation.account.user_reward_managers.clear(); + obligation.account + }, + &mut new_raw_obligation.data, + ) + .expect("Should pack obligation"); + + test.context + .set_account(&obligation.pubkey, &new_raw_obligation.into()); + } + + let reward_mint = test.create_mint_as_test_authority().await; + let reward_vault = Keypair::new(); + let duration_secs = 3_600; + let total_rewards = 1_000_000; + let initial_time = test.get_clock().await.unix_timestamp as u64; + let reward = LiqMiningReward { + mint: reward_mint, + vault: reward_vault.insecure_clone(), + }; + + lending_market + .add_pool_reward( + &mut test, + &usdc_reserve, + &mut lending_market_owner, + &reward, + PositionKind::Deposit, + initial_time, + initial_time + duration_secs as u64, + total_rewards, + ) + .await + .expect("Should add pool reward"); + + let current_time = test + .advance_clock_by_slots_and_secs(1, duration_secs as u64 / 2) + .await; + + // user must have a token account to deposit rewards into ahead of time + user.create_token_account(&reward.mint, &mut test).await; + + let balance_checker = BalanceChecker::start(&mut test, &[&user]).await; + + // At this point the user did not have any shares in the obligation and so + // they cannot claim anything. + // However, we migrate the obligation so that next time they claim they do + // get something. + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + ) + .await + .expect("Should claim reward"); + + let usdc_reserve_post = test.load_account::(usdc_reserve.pubkey).await; + + assert_eq!( + usdc_reserve_post + .account + .deposits_pool_reward_manager + .total_shares, + 2 // 1 from the old obligation and 1 from the new one + ); + + let obligation_post = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_post.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![UserReward { + pool_reward_index: 0, + pool_reward_id: PoolRewardId(1), + earned_rewards: Decimal::zero(), + cumulative_rewards_per_share: Decimal::from_scaled_val(500000_000000000000000000), + }], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!(balance_changes, HashSet::new()); + + let current_time = test.advance_clock_by_slots_and_secs(1, duration_secs).await; + + // now they should be able to claim rewards + + lending_market + .claim_pool_reward( + &mut test, + &obligation, + &usdc_reserve, + &user, + &reward, + PositionKind::Deposit, + ) + .await + .expect("Should claim reward"); + + let obligation_final = test.load_obligation(obligation.pubkey).await; + + assert_eq!( + obligation_final.account.user_reward_managers[0], + UserRewardManager { + reserve: usdc_reserve.pubkey, + position_kind: PositionKind::Deposit, + share: 1, + last_update_time_secs: current_time, + rewards: vec![], + } + ); + + let (balance_changes, _) = balance_checker.find_balance_changes(&mut test).await; + assert_eq!( + balance_changes, + HashSet::from([TokenBalanceChange { + token_account: user.get_account(&reward.mint).unwrap(), + mint: reward.mint, + // There are 2 shares and we're accruing rewards for half the time. + // There are 2 shares bcs we reset the obligation and "register" it + // a second time in this test. + diff: (total_rewards as i128) / 4, + }]) + ); +} diff --git a/token-lending/program/tests/close_pool_reward.rs b/token-lending/program/tests/close_pool_reward.rs index 155af89836a..1493d8a8e8f 100644 --- a/token-lending/program/tests/close_pool_reward.rs +++ b/token-lending/program/tests/close_pool_reward.rs @@ -28,13 +28,12 @@ async fn test_close_pool_reward_for_borrow() { async fn test_(position_kind: PositionKind) { let (mut test, lending_market, usdc_reserve, _, mut lending_market_owner, _) = setup_world(&test_reserve_config(), &test_reserve_config()).await; - let mut clock = test.get_clock().await; let reward_mint = test.create_mint_as_test_authority().await; let reward_vault = Keypair::new(); let duration_secs = 3_600; let total_rewards = 1_000_000; - let initial_time = clock.unix_timestamp as u64; + let initial_time = test.get_clock().await.unix_timestamp as u64; let reward = LiqMiningReward { mint: reward_mint, vault: reward_vault.insecure_clone(), @@ -57,8 +56,7 @@ async fn test_(position_kind: PositionKind) { let balance_checker = BalanceChecker::start(&mut test, &[&lending_market_owner]).await; // doesn't matter when we close as long as there are no obligations - clock.unix_timestamp += 1; - test.context.set_sysvar(&clock); + test.advance_clock_by_slots_and_secs(1, 1).await; let pool_reward_index = 0; lending_market @@ -88,11 +86,7 @@ async fn test_(position_kind: PositionKind) { total_shares: 0, last_update_time_secs: initial_time as _, pool_rewards: { - let mut og = usdc_reserve - .account - .deposits_pool_reward_manager - .pool_rewards - .clone(); + let mut og = PoolRewardManager::default().pool_rewards; og[0] = PoolRewardSlot::Vacant { last_pool_reward_id: PoolRewardId(1), diff --git a/token-lending/program/tests/helpers/solend_program_test.rs b/token-lending/program/tests/helpers/solend_program_test.rs index d50e4ea6e51..1f45cabdbbf 100644 --- a/token-lending/program/tests/helpers/solend_program_test.rs +++ b/token-lending/program/tests/helpers/solend_program_test.rs @@ -77,6 +77,7 @@ mod cu_budgets { pub(super) const ADD_POOL_REWARD: u32 = 80_017; pub(super) const CANCEL_POOL_REWARD: u32 = 80_018; pub(super) const CLOSE_POOL_REWARD: u32 = 80_019; + pub(super) const CLAIM_POOL_REWARD: u32 = 80_020; } /// This is at most how many bytes can an obligation grow. @@ -337,6 +338,16 @@ impl SolendProgramTest { .await } + /// Returns the new clock unix timestamp + pub async fn advance_clock_by_slots_and_secs(&mut self, slots: u64, secs: u64) -> u64 { + self.advance_clock_by_slots(slots).await; + let mut clock = self.get_clock().await; + clock.unix_timestamp += secs as i64; + self.context.set_sysvar(&clock); + + clock.unix_timestamp as u64 + } + /// Advances clock by x slots. note that transactions don't automatically increment the slot /// value in Clock, so this function must be explicitly called whenever you want time to move /// forward. @@ -1048,6 +1059,41 @@ impl Info { .await } + pub async fn claim_pool_reward( + &self, + test: &mut SolendProgramTest, + obligation: &Info, + reserve: &Info, + obligation_owner: &User, + reward: &LiqMiningReward, + position_kind: PositionKind, + ) -> Result<(), BanksClientError> { + let (reward_authority_pda, reward_authority_bump) = find_reward_vault_authority( + &solend_program::id(), + &self.pubkey, + &reserve.pubkey, + &reward.mint, + ); + + let instructions = [ + ComputeBudgetInstruction::set_compute_unit_limit(cu_budgets::CLAIM_POOL_REWARD), + claim_pool_reward( + solend_program::id(), + reward_authority_bump, + position_kind, + obligation.pubkey, + obligation_owner.get_account(&reward.mint).unwrap(), + reserve.pubkey, + reward.mint, + reward_authority_pda, + reward.vault.pubkey(), + self.pubkey, + ), + ]; + + test.process_transaction(&instructions, None).await + } + pub async fn donate_to_reserve( &self, test: &mut SolendProgramTest, diff --git a/token-lending/sdk/src/instruction.rs b/token-lending/sdk/src/instruction.rs index 5b3e9d034ec..e7e4a251b68 100644 --- a/token-lending/sdk/src/instruction.rs +++ b/token-lending/sdk/src/instruction.rs @@ -621,10 +621,10 @@ pub enum LendingInstruction { /// 28 /// ClaimReward /// - /// * User can claim rewards from their obligation. + /// * Permissionless claim of rewards from an obligation. /// /// `[writable]` Obligation account. - /// `[writable]` Obligation owner reward receiving token account. + /// `[writable]` Obligation owner's token account that receives reward. /// `[writable]` Reserve account. /// `[]` Reward mint. /// `[]` Derived reserve pool reward authority. Seed: @@ -638,6 +638,10 @@ pub enum LendingInstruction { ClaimReward { /// The bump seed of the reward authority. reward_authority_bump: u8, + /// Even though an obligation can either deposit or borrow the same + /// reserve, the obligation's rewards can hold rewards for both. + /// It's therefore necessary to specify which kind of reward to claim. + position_kind: PositionKind, }, // 255 @@ -944,9 +948,11 @@ impl LendingInstruction { } } 28 => { - let (reward_authority_bump, _rest) = Self::unpack_u8(rest)?; + let (reward_authority_bump, rest) = Self::unpack_u8(rest)?; + let (position_kind, _rest) = Self::unpack_try_from_u8(rest)?; Self::ClaimReward { reward_authority_bump, + position_kind, } } 255 => Self::UpgradeReserveToV2_1_0, @@ -1294,9 +1300,11 @@ impl LendingInstruction { } Self::ClaimReward { reward_authority_bump, + position_kind, } => { buf.push(28); buf.extend_from_slice(&reward_authority_bump.to_le_bytes()); + buf.extend_from_slice(&(position_kind as u8).to_le_bytes()); } Self::UpgradeReserveToV2_1_0 => { buf.push(255); @@ -2243,6 +2251,51 @@ pub fn close_pool_reward( } } +/// `[writable]` Obligation account. +/// `[writable]` Obligation owner's token account that receives reward. +/// `[writable]` Reserve account. +/// `[]` Reward mint. +/// `[]` Derived reserve pool reward authority. Seed: +/// * b"RewardVaultAuthority" +/// * Lending market account pubkey +/// * Reserve account pubkey +/// * Reward mint pubkey +/// `[writable]` Reward vault token account. +/// `[]` Lending market account. +/// `[]` Token program. +#[allow(clippy::too_many_arguments)] +pub fn claim_pool_reward( + program_id: Pubkey, + reward_authority_bump: u8, + position_kind: PositionKind, + obligation: Pubkey, + obligation_owner_token_account_for_reward: Pubkey, + reserve: Pubkey, + reward_mint: Pubkey, + reward_vault_authority: Pubkey, + reward_vault: Pubkey, + lending_market: Pubkey, +) -> Instruction { + Instruction { + program_id, + accounts: vec![ + AccountMeta::new(obligation, false), + AccountMeta::new(obligation_owner_token_account_for_reward, false), + AccountMeta::new(reserve, false), + AccountMeta::new_readonly(reward_mint, false), + AccountMeta::new_readonly(reward_vault_authority, false), + AccountMeta::new(reward_vault, false), + AccountMeta::new_readonly(lending_market, false), + AccountMeta::new_readonly(spl_token::id(), false), + ], + data: LendingInstruction::ClaimReward { + reward_authority_bump, + position_kind, + } + .pack(), + } +} + /// Derives the reward vault authority PDA address. pub fn find_reward_vault_authority( program_id: &Pubkey, diff --git a/token-lending/sdk/src/state/liquidity_mining.rs b/token-lending/sdk/src/state/liquidity_mining.rs index fe561e78d91..16886e522b7 100644 --- a/token-lending/sdk/src/state/liquidity_mining.rs +++ b/token-lending/sdk/src/state/liquidity_mining.rs @@ -24,8 +24,6 @@ pub const MIN_REWARD_PERIOD_SECS: u64 = 3_600; mod suilend_tests { //! These tests were taken from the Suilend's codebase and adapted to //! the new codebase. - //! - //! TODO: Calculate test coverage and add tests for missing branches. use crate::{ math::Decimal, diff --git a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs index ecfc83a9ccb..b255b88a7e6 100644 --- a/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs +++ b/token-lending/sdk/src/state/liquidity_mining/pool_reward_manager.rs @@ -415,7 +415,6 @@ impl Pack for PoolRewardManager { *dst_total_rewards = pool_reward.total_rewards.to_le_bytes(); *dst_num_user_reward_managers = pool_reward.num_user_reward_managers.to_le_bytes(); - // TBD: do we want to ceil? pack_decimal( pool_reward.cumulative_rewards_per_share, dst_cumulative_rewards_per_share_wads, diff --git a/token-lending/sdk/src/state/obligation.rs b/token-lending/sdk/src/state/obligation.rs index b2661d7bf08..b1c85e6e560 100644 --- a/token-lending/sdk/src/state/obligation.rs +++ b/token-lending/sdk/src/state/obligation.rs @@ -485,11 +485,11 @@ impl ObligationLiquidity { const OBLIGATION_COLLATERAL_LEN: usize = 88; // 32 + 8 + 16 + 32 const OBLIGATION_LIQUIDITY_LEN: usize = 112; // 32 + 16 + 16 + 16 + 32 /// This is the size of the account _before_ LM feature was added. -const OBLIGATION_LEN_V1: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) - // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca +const OBLIGATION_LEN_V2_0_2: usize = 1300; // 1 + 8 + 1 + 32 + 32 + 16 + 16 + 16 + 16 + 64 + 1 + 1 + (88 * 1) + (112 * 9) + // @TODO: break this up by obligation / collateral / liquidity https://git.io/JOCca impl Obligation { /// Obligation with no Liquidity Mining Rewards - pub const MIN_LEN: usize = OBLIGATION_LEN_V1; + pub const MIN_LEN: usize = OBLIGATION_LEN_V2_0_2; /// Maximum account size for obligation. /// Scenario in which all reserves have all associated rewards filled. @@ -502,10 +502,10 @@ impl Obligation { /// How many bytes are needed to pack this [UserRewardManager]. pub fn size_in_bytes_when_packed(&self) -> usize { if self.user_reward_managers.is_empty() { - return OBLIGATION_LEN_V1; + return OBLIGATION_LEN_V2_0_2; } - let mut size = OBLIGATION_LEN_V1 + 1; + let mut size = OBLIGATION_LEN_V2_0_2 + 1; for reward_manager in self.user_reward_managers.iter() { size += reward_manager.size_in_bytes_when_packed(); @@ -554,7 +554,7 @@ impl Obligation { /// Since @v2.1.0 we pack vec of user reward managers pub fn pack_into_slice(&self, dst: &mut [u8]) { - let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V1]; + let output = array_mut_ref![dst, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( discriminator, @@ -666,17 +666,17 @@ impl Obligation { debug_assert!(MAX_OBLIGATION_RESERVES >= self.user_reward_managers.len()); debug_assert!(u8::MAX > MAX_OBLIGATION_RESERVES as _); let user_reward_managers_len = self.user_reward_managers.len() as u8; - dst[OBLIGATION_LEN_V1] = user_reward_managers_len; + dst[OBLIGATION_LEN_V2_0_2] = user_reward_managers_len; - let mut offset = OBLIGATION_LEN_V1 + 1; + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for user_reward_manager in self.user_reward_managers.iter() { user_reward_manager.pack_into_slice(&mut dst[offset..]); offset += user_reward_manager.size_in_bytes_when_packed(); } - } else if dst.len() > OBLIGATION_LEN_V1 { + } else if dst.len() > OBLIGATION_LEN_V2_0_2 { // set the length to 0 if obligation was resized before - dst[OBLIGATION_LEN_V1] = 0; + dst[OBLIGATION_LEN_V2_0_2] = 0; }; // Any data after offset is garbage, but we don't zero it out bcs @@ -686,7 +686,7 @@ impl Obligation { /// Unpacks a byte buffer into an [Obligation]. /// Since @v2.1.0 we unpack vector of user reward managers pub fn unpack_from_slice(src: &[u8]) -> Result { - let input = array_ref![src, 0, OBLIGATION_LEN_V1]; + let input = array_ref![src, 0, OBLIGATION_LEN_V2_0_2]; #[allow(clippy::ptr_offset_with_cast)] let ( discriminator, @@ -738,7 +738,7 @@ impl Obligation { } Err(LendingError::AccountNotMigrated) => { // We're migrating the account from v2.0.2 to v2.1.0. - debug_assert_eq!(OBLIGATION_LEN_V1, input.len()); + debug_assert_eq!(OBLIGATION_LEN_V2_0_2, input.len()); AccountDiscriminator::Obligation } @@ -788,11 +788,11 @@ impl Obligation { offset += OBLIGATION_LIQUIDITY_LEN; } - let user_reward_managers = match src.get(OBLIGATION_LEN_V1) { + let user_reward_managers = match src.get(OBLIGATION_LEN_V2_0_2) { Some(len @ 1..) => { let mut user_reward_managers = Vec::with_capacity(*len as _); - let mut offset = OBLIGATION_LEN_V1 + 1; + let mut offset = OBLIGATION_LEN_V2_0_2 + 1; for _ in 0..*len { let user_reward_manager = UserRewardManager::unpack_from_slice(&src[offset..])?; offset += user_reward_manager.size_in_bytes_when_packed(); diff --git a/token-lending/sdk/src/state/reserve.rs b/token-lending/sdk/src/state/reserve.rs index d2f4def8193..7f59f39384a 100644 --- a/token-lending/sdk/src/state/reserve.rs +++ b/token-lending/sdk/src/state/reserve.rs @@ -594,6 +594,14 @@ impl Reserve { )) } + /// Returns the pool reward manager for the given position kind + pub fn pool_reward_manager(&self, position_kind: PositionKind) -> &PoolRewardManager { + match position_kind { + PositionKind::Borrow => &self.borrows_pool_reward_manager, + PositionKind::Deposit => &self.deposits_pool_reward_manager, + } + } + /// Returns the pool reward manager for the given position kind pub fn pool_reward_manager_mut( &mut self, diff --git a/token-lending/tests/liquidity-mining.ts b/token-lending/tests/liquidity-mining.ts index d3c988d6961..d8cd3aa2c8c 100644 --- a/token-lending/tests/liquidity-mining.ts +++ b/token-lending/tests/liquidity-mining.ts @@ -1,4 +1,7 @@ /** + * Temporary test to showcase that reserve upgrades work with CLI. + * We'll delete this once all reserves are upgraded. + * * $ anchor test --provider.cluster localnet --detach */ @@ -57,7 +60,7 @@ describe("liquidity mining", () => { .getProvider() .connection.getAccountInfo(new PublicKey(TEST_RESERVE_FOR_UPGRADE)); - expect(reserveAfter.data.length).to.eq(8651); // new version data length + expect(reserveAfter.data.length).to.eq(5451); // new version data length const expectedRentAfter = await anchor .getProvider() .connection.getMinimumBalanceForRentExemption(reserveAfter.data.length);