Skip to content

Commit 81cafd5

Browse files
authored
Liquidity Mining: Addressing audit review (#219)
* Addressing audit review * Increasing CU limit * Addressing audit review * Addressing clippy suggestions and increasing CU limits
1 parent 4137879 commit 81cafd5

File tree

9 files changed

+283
-79
lines changed

9 files changed

+283
-79
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

token-lending/program/Cargo.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ test-bpf = []
1515

1616
[dependencies]
1717
bytemuck = "1.5.1"
18-
# pyth-sdk-solana = "0.8.0"
19-
# pyth-solana-receiver-sdk = "0.3.0"
18+
oracles = { path = "../oracles" }
2019
solana-program = "=1.16.20"
2120
solend-sdk = { path = "../sdk" }
22-
oracles = { path = "../oracles" }
21+
spl-associated-token-account = "1.0.5" # compatible with spl-token 3.3.0
2322
spl-token = { version = "3.3.0", features = ["no-entrypoint"] }
2423
static_assertions = "1.1.0"
2524

token-lending/program/src/processor/liquidity_mining/claim_user_reward.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ use solana_program::{
1919
pubkey::Pubkey,
2020
sysvar::Sysvar,
2121
};
22-
use solend_sdk::state::{Obligation, PositionKind};
22+
use solend_sdk::state::{HasRewardEnded, Obligation, PositionKind};
2323
use solend_sdk::{error::LendingError, instruction::reward_vault_authority_seeds};
24+
use spl_associated_token_account::get_associated_token_address_with_program_id;
2425

2526
use super::{
2627
check_and_unpack_pool_reward_accounts, unpack_token_account, Bumps,
@@ -29,6 +30,8 @@ use super::{
2930

3031
/// Use [Self::from_unchecked_iter] to validate the accounts.
3132
struct ClaimUserReward<'a, 'info> {
33+
/// ✅ is_signer
34+
perhaps_payer_info: Option<&'a AccountInfo<'info>>,
3235
/// ✅ belongs to this program
3336
/// ✅ unpacks
3437
/// ✅ matches `lending_market_info`
@@ -37,7 +40,7 @@ struct ClaimUserReward<'a, 'info> {
3740
/// ✅ belongs to the token program
3841
/// ✅ is writable
3942
/// ✅ matches `reward_mint_info`
40-
/// ✅ owned by the obligation owner
43+
/// ✅ is obligation owner's ATA for the reward mint
4144
obligation_owner_token_account_info: &'a AccountInfo<'info>,
4245
/// ✅ belongs to this program
4346
/// ✅ unpacks
@@ -88,11 +91,22 @@ pub(crate) fn process(
8891
)?;
8992
let reserve_key = accounts.reserve.key();
9093

94+
// AUDIT:
95+
// > ClaimUserReward doesn’t check if the Obligation is stale.
96+
// > This can cause problems for borrow rewards, because the obligation's liability_shares will
97+
// > be stale.
98+
if matches!(position_kind, PositionKind::Borrow)
99+
&& accounts.obligation.last_update.is_stale(clock.slot)?
100+
{
101+
msg!("obligation is stale and must be refreshed in the current slot");
102+
return Err(LendingError::ObligationStale.into());
103+
}
104+
91105
// 1.
92106

93107
let pool_reward_manager = accounts.reserve.pool_reward_manager_mut(position_kind);
94108

95-
if let Some(user_reward_manager) = accounts
109+
if let Some((_, user_reward_manager)) = accounts
96110
.obligation
97111
.user_reward_managers
98112
.find_mut(reserve_key, position_kind)
@@ -106,12 +120,23 @@ pub(crate) fn process(
106120

107121
// 2.
108122

109-
let total_reward_amount = user_reward_manager.claim_rewards(
123+
let (has_ended, total_reward_amount) = user_reward_manager.claim_rewards(
110124
pool_reward_manager,
111125
*accounts.reward_token_vault_info.key,
112126
clock,
113127
)?;
114128

129+
// AUDIT:
130+
// > ClaimUserReward on Suilend can only be called permissionlessly if the reward period is
131+
// > fully elapsed.
132+
let payer_matches_obligation_owner = accounts
133+
.perhaps_payer_info
134+
.map_or(false, |payer| payer.key == &accounts.obligation.owner);
135+
if !matches!(has_ended, HasRewardEnded::Yes) && !payer_matches_obligation_owner {
136+
msg!("User reward manager has not ended, but payer does not match obligation owner");
137+
return Err(LendingError::InvalidSigner.into());
138+
}
139+
115140
// 3.
116141

117142
if total_reward_amount > 0 {
@@ -204,6 +229,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> {
204229
let reward_token_vault_info = next_account_info(iter)?;
205230
let lending_market_info = next_account_info(iter)?;
206231
let token_program_info = next_account_info(iter)?;
232+
let perhaps_payer_info = next_account_info(iter).ok();
207233

208234
let (_, reserve) = check_and_unpack_pool_reward_accounts(
209235
program_id,
@@ -218,6 +244,11 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> {
218244
},
219245
)?;
220246

247+
if perhaps_payer_info.map(|a| !a.is_signer).unwrap_or(false) {
248+
msg!("Payer account must be a signer");
249+
return Err(LendingError::InvalidSigner.into());
250+
}
251+
221252
if obligation_info.owner != program_id {
222253
msg!("Obligation provided is not owned by the lending program");
223254
return Err(LendingError::InvalidAccountOwner.into());
@@ -230,19 +261,29 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> {
230261
return Err(LendingError::InvalidAccountInput.into());
231262
}
232263

264+
// AUDIT:
265+
// > In ClaimUserReward, because this is a permissionless instruction, we recommend
266+
// > validating that obligation_owner_token_account_info is an associated token account
267+
// > (ATA), rather than only a token account owned by the obligation owner.
268+
// > Allowing arbitrary token accounts would require indexing each one, adding unnecessary
269+
// > complexity and risk.
270+
let expected_ata = get_associated_token_address_with_program_id(
271+
&obligation.owner,
272+
reward_mint_info.key,
273+
token_program_info.key,
274+
);
275+
if expected_ata != *obligation_owner_token_account_info.key {
276+
msg!("Token account for collecting rewards must be ATA");
277+
return Err(LendingError::InvalidAccountInput.into());
278+
}
279+
233280
if obligation_owner_token_account_info.owner != token_program_info.key {
234281
msg!("Obligation owner token account provided must be owned by the token program");
235282
return Err(LendingError::InvalidTokenOwner.into());
236283
}
237284
let obligation_owner_token_account =
238285
unpack_token_account(&obligation_owner_token_account_info.data.borrow())?;
239286

240-
if obligation_owner_token_account.owner != obligation.owner {
241-
msg!(
242-
"Obligation owner token account owner does not match the obligation owner provided"
243-
);
244-
return Err(LendingError::InvalidAccountInput.into());
245-
}
246287
if obligation_owner_token_account.mint != *reward_mint_info.key {
247288
msg!("Obligation owner token account mint does not match the reward mint provided");
248289
return Err(LendingError::InvalidAccountInput.into());
@@ -283,6 +324,7 @@ impl<'a, 'info> ClaimUserReward<'a, 'info> {
283324
}
284325

285326
Ok(Self {
327+
perhaps_payer_info,
286328
obligation_info,
287329
obligation_owner_token_account_info,
288330
_reserve_info: reserve_info,

token-lending/program/src/processor/liquidity_mining/upgrade_reserve.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use solana_program::{
1111
sysvar::Sysvar,
1212
};
1313
use solend_sdk::state::discriminator::AccountDiscriminator;
14-
use solend_sdk::state::RESERVE_LEN_V2_0_2;
14+
use solend_sdk::state::{PROGRAM_VERSION_2_0_2, RESERVE_LEN_V2_0_2};
1515
use solend_sdk::{error::LendingError, state::Reserve};
1616

1717
struct UpgradeReserveAccounts<'a, 'info> {
@@ -121,6 +121,20 @@ impl<'a, 'info> UpgradeReserveAccounts<'a, 'info> {
121121
return Err(LendingError::InvalidAccountInput.into());
122122
}
123123

124+
// AUDIT:
125+
// > UpgradeReserve should verify that a Reserve was previously stored in the account before
126+
// > performing the upgrade.
127+
// > This doesn’t appear to be exploitable—it would just allow an attacker to create
128+
// > a valid-looking Reserve filled with zeros—but it’s worth addressing.
129+
// > Anybody can create an account owned by solend with size == RESERVE_LEN_V2_0_2 by
130+
// > calling system_instruction::create_account, so you can't only rely on checking the
131+
// > account length && ownership.
132+
// > Asserting that the first byte is equal to the expected old version would fix it.
133+
if reserve_info.data.borrow()[0] != PROGRAM_VERSION_2_0_2 {
134+
msg!("Reserve provided must be a v2.0.2 reserve");
135+
return Err(LendingError::InvalidAccountInput.into());
136+
}
137+
124138
if system_program.key != &solana_program::system_program::id() {
125139
msg!("System program provided must be the system program");
126140
return Err(LendingError::InvalidAccountInput.into());

token-lending/program/tests/claim_pool_reward.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,32 @@ async fn test_(position_kind: PositionKind) {
116116
.await;
117117

118118
// user must have a token account to deposit rewards into ahead of time
119-
user.create_token_account(&reward.mint, &mut test).await;
119+
user.create_associated_token_account(&reward.mint, &mut test)
120+
.await;
120121

121122
let balance_checker =
122123
BalanceChecker::start(&mut test, &[&TokenAccount(reward.vault.pubkey()), &user]).await;
123124

125+
let err = lending_market
126+
.claim_pool_reward(
127+
&mut test,
128+
&obligation,
129+
&usdc_reserve,
130+
&user,
131+
&reward,
132+
position_kind,
133+
None,
134+
)
135+
.await
136+
.expect_err("Cannot claim reward before it ends unless owner");
137+
138+
match err.unwrap() {
139+
TransactionError::InstructionError(_, InstructionError::Custom(err_code)) => {
140+
assert_eq!(err_code, LendingError::InvalidSigner as u32);
141+
}
142+
_ => panic!("Expected LendingError::InvalidSigner, got: {:?}", err),
143+
};
144+
124145
lending_market
125146
.claim_pool_reward(
126147
&mut test,
@@ -129,6 +150,7 @@ async fn test_(position_kind: PositionKind) {
129150
&user,
130151
&reward,
131152
position_kind,
153+
Some(&user),
132154
)
133155
.await
134156
.expect("Should claim reward");
@@ -233,6 +255,7 @@ async fn test_(position_kind: PositionKind) {
233255
&user,
234256
&reward,
235257
position_kind,
258+
None,
236259
)
237260
.await
238261
.expect("Should claim reward");
@@ -334,6 +357,7 @@ async fn test_cannot_claim_into_wrong_destination() {
334357
&lending_market_owner, // ! wrong
335358
&reward,
336359
PositionKind::Deposit,
360+
None,
337361
)
338362
.await
339363
.expect_err("Cannot steal user reward");
@@ -430,7 +454,8 @@ async fn test_migrate_obligation() {
430454
.await;
431455

432456
// user must have a token account to deposit rewards into ahead of time
433-
user.create_token_account(&reward.mint, &mut test).await;
457+
user.create_associated_token_account(&reward.mint, &mut test)
458+
.await;
434459

435460
let balance_checker = BalanceChecker::start(&mut test, &[&user]).await;
436461

@@ -447,6 +472,7 @@ async fn test_migrate_obligation() {
447472
&user,
448473
&reward,
449474
PositionKind::Deposit,
475+
None,
450476
)
451477
.await
452478
.expect("Should claim reward");
@@ -494,6 +520,7 @@ async fn test_migrate_obligation() {
494520
&user,
495521
&reward,
496522
PositionKind::Deposit,
523+
None,
497524
)
498525
.await
499526
.expect("Should claim reward");

0 commit comments

Comments
 (0)