Skip to content

Commit 9bd4002

Browse files
committed
Harden lending example after security review
- Price feed PDAs are now seeded [b"price_feed", authority, mint]: a signer can only write the feed derived from their own key, removing the first-caller-claims race on the previous per-mint feed. Reserves trust exactly the feed their market owner registered. - Lending markets are now isolation boundaries: every obligation handler (deposit/withdraw collateral, borrow, repay, liquidate, refresh) rejects reserves whose lending_market differs from the obligation's (MarketMismatch). - Liquidation reads the close factor from the repay reserve (a property of the debt) and the bonus from the collateral reserve (a property of the seized asset), and rejects repayments whose seizure would exceed posted collateral (LiquidationTooLarge) instead of silently capping, which made the liquidator pay full price for less collateral. - Withdraw health checks round the removed borrow power up at every step, so independent flooring can never let a withdraw pass that an exact recompute would reject. - Documented share_mint_supply drift from direct token-program burns (protocol-favourable), the transfer-fee mint limitation, instant config changes, and the audit expectation; moved reserve_signer_seeds from math.rs to state/reserve.rs. - New tests: cross-market reserve rejection, foreign-signer feed write rejection, over-seizing liquidation rejection (21 tests total). https://claude.ai/code/session_01RwE8f8ahP5S6SDNTsXmpj9
1 parent adb4977 commit 9bd4002

18 files changed

Lines changed: 386 additions & 123 deletions

finance/lending/anchor/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ Initial lending program: a Kamino/Solend-style borrow/lend market.
1515
- Rust + LiteSVM integration tests covering supply/redeem, borrow/repay,
1616
withdraw, interest accrual, liquidation, the share-inflation guard, and
1717
rounding/stale-input edge cases.
18+
- Lending markets are isolation boundaries: every obligation handler rejects
19+
reserves from another market (`MarketMismatch`).
20+
- Price feed PDAs are seeded by their authority, so no signer can write or
21+
pre-claim a feed another authority's reserves trust.
22+
- Liquidation reads the close factor from the repay reserve, the bonus from the
23+
collateral reserve, and rejects repayments whose seizure would exceed the
24+
posted collateral (`LiquidationTooLarge`).
25+
- Withdraw health checks round the removed borrow power up, so independent
26+
rounding can never let a withdraw pass that an exact recompute would reject.

finance/lending/anchor/README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ Borrowing and withdrawing are gated by `allowed_borrow_value`; an obligation is
6969
liquidatable once `borrowed_value > unhealthy_borrow_value`. Collateral is valued
7070
rounding down and debt rounding up, so health is always judged conservatively.
7171

72+
Every handler that pairs an obligation with a reserve requires both to belong to
73+
the same `LendingMarket` (`MarketMismatch` otherwise), so each market is an
74+
isolation boundary: positions in one market can never be valued or settled
75+
against reserves of another.
76+
77+
In a liquidation, the close factor (how much of the borrow one call may repay)
78+
comes from the **repay reserve**, because it is a property of the debt being
79+
closed; the liquidation bonus comes from the **collateral reserve**, because it
80+
prices the collateral being seized. A repayment whose seizure would exceed the
81+
posted collateral fails with `LiquidationTooLarge` rather than silently seizing
82+
less, which would make the liquidator overpay.
83+
7284
### Fixed-point math
7385

7486
All money math is integer-only `u128` — no floats, no fixed-point crates. Ratios
@@ -82,11 +94,17 @@ round-trips.
8294
`PriceFeed` mirrors a Switchboard On-Demand pull feed: a signed mantissa, an
8395
exponent (`price = mantissa * 10^exponent`), and the slot the price was written.
8496
Freshness is checked in **slots** (`MAX_PRICE_STALENESS_SLOTS`), not wall-clock
85-
time. The `set_price` handler writes the feed directly so the LiteSVM tests are
97+
time. The feed PDA is seeded by `[b"price_feed", authority, mint]`, so a signer
98+
can only ever write the feed derived from their own key — there is no shared
99+
per-mint feed to claim first — and a reserve trusts exactly one feed: the
100+
account its market owner passed to `init_reserve`.
101+
102+
The `set_price` handler writes the feed directly so the LiteSVM tests are
86103
deterministic; in production a reserve points at the real Switchboard feed and the
87104
program decodes `PullFeedAccountData` (`price_mantissa = current_result.value`,
88-
`exponent = -18`, `last_updated_slot = current_result.slot`) instead. Switchboard
89-
is used rather than Pyth here for its lower compute cost.
105+
`exponent = -18`, `last_updated_slot = current_result.slot`) instead, and should
106+
also reject results whose confidence interval is too wide. Switchboard is used
107+
rather than Pyth here for its lower compute cost.
90108

91109
### Custody
92110

@@ -95,6 +113,20 @@ per-obligation vault PDAs whose authority is the obligation PDA. The market owne
95113
can update reserve risk parameters (`update_reserve_config`) but has no path to
96114
move user funds — there is no admin withdrawal or escape hatch.
97115

116+
### Known limits
117+
118+
- **Tokens with transfer fees are not supported.** The program uses
119+
`token_interface`, so Token Extensions mints are accepted, but a transfer-fee
120+
extension would make the vault receive less than the recorded deposit and the
121+
accounting would overstate `available_liquidity`. Production protocols
122+
whitelist mints; a market owner here must only create reserves for tokens
123+
without transfer fees.
124+
- **Reserve config changes act immediately.** Lowering a reserve's
125+
`liquidation_threshold_bps` can make existing obligations liquidatable at
126+
once; production governance phases such changes in.
127+
- This is an example. Deploying any program that custodies funds calls for a
128+
professional security audit first.
129+
98130
### Instruction handlers
99131

100132
Admin: `init_lending_market`, `init_reserve`, `update_reserve_config`, `set_price`.

finance/lending/anchor/programs/lending/src/errors.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub enum LendingError {
3232
ReserveNotFound,
3333
#[msg("A refresh account did not match the obligation's stored reserves")]
3434
InvalidObligationAccount,
35-
#[msg("Signer is not authorized for this price feed")]
36-
UnauthorizedPriceFeed,
35+
#[msg("Reserve belongs to a different lending market than the obligation")]
36+
MarketMismatch,
37+
#[msg("Repay amount would seize more collateral than the obligation holds")]
38+
LiquidationTooLarge,
3739
}

finance/lending/anchor/programs/lending/src/instructions/admin/set_price.rs

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,25 @@ use anchor_lang::prelude::*;
22
use anchor_spl::token_interface::Mint;
33

44
use crate::constants::PRICE_FEED_SEED;
5-
use crate::errors::LendingError;
65
use crate::state::PriceFeed;
76

87
/// Test stand-in for a Switchboard On-Demand feed: writes a price directly so
98
/// LiteSVM tests are deterministic. In production the reserve points at a real
109
/// Switchboard feed instead and this handler is unused.
10+
///
11+
/// The feed PDA is seeded by `[b"price_feed", authority, mint]`, so each
12+
/// authority can only ever write its own feed — there is no shared per-mint
13+
/// feed to race for. A reserve trusts exactly one feed account: the one the
14+
/// market owner passed to `init_reserve`.
1115
pub fn handle_set_price(
1216
context: Context<SetPrice>,
1317
price_mantissa: i128,
1418
exponent: i32,
1519
) -> Result<()> {
1620
let feed = &mut context.accounts.price_feed;
17-
18-
// On first creation the authority is unset (default Pubkey); claim it for
19-
// the signer. On later updates only that authority may write.
20-
if feed.authority == Pubkey::default() {
21-
feed.authority = context.accounts.authority.key();
22-
feed.mint = context.accounts.mint.key();
23-
feed.bump = context.bumps.price_feed;
24-
} else {
25-
require_keys_eq!(
26-
feed.authority,
27-
context.accounts.authority.key(),
28-
LendingError::UnauthorizedPriceFeed
29-
);
30-
}
31-
21+
feed.authority = context.accounts.authority.key();
22+
feed.mint = context.accounts.mint.key();
23+
feed.bump = context.bumps.price_feed;
3224
feed.price_mantissa = price_mantissa;
3325
feed.exponent = exponent;
3426
feed.last_updated_slot = Clock::get()?.slot;
@@ -37,11 +29,13 @@ pub fn handle_set_price(
3729

3830
#[derive(Accounts)]
3931
pub struct SetPrice<'info> {
32+
// The authority is part of the seeds: a signer can only ever address (and
33+
// therefore write) the feed derived from their own key.
4034
#[account(
4135
init_if_needed,
4236
payer = authority,
4337
space = PriceFeed::DISCRIMINATOR.len() + PriceFeed::INIT_SPACE,
44-
seeds = [PRICE_FEED_SEED, mint.key().as_ref()],
38+
seeds = [PRICE_FEED_SEED, authority.key().as_ref(), mint.key().as_ref()],
4539
bump,
4640
)]
4741
pub price_feed: Account<'info, PriceFeed>,

finance/lending/anchor/programs/lending/src/instructions/borrow_obligation_liquidity.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use anchor_spl::token_interface::{
55

66
use crate::constants::FIXED_POINT_SCALE;
77
use crate::errors::LendingError;
8-
use crate::math::{market_value, mul_div_ceil, reserve_signer_seeds, Rounding};
9-
use crate::state::{Obligation, PriceFeed, Reserve};
8+
use crate::math::{market_value, mul_div_ceil, Rounding};
9+
use crate::state::{reserve_signer_seeds, Obligation, PriceFeed, Reserve};
1010

1111
/// Borrow liquidity against the obligation's collateral. The new debt's value
1212
/// (rounded up) plus the existing debt must stay within the obligation's
@@ -103,6 +103,7 @@ pub struct BorrowObligationLiquidity<'info> {
103103
has_one = liquidity_mint,
104104
has_one = liquidity_vault,
105105
has_one = price_feed,
106+
constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch,
106107
)]
107108
pub reserve: Account<'info, Reserve>,
108109

finance/lending/anchor/programs/lending/src/instructions/deposit_obligation_collateral.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ pub struct DepositObligationCollateral<'info> {
5151
#[account(mut)]
5252
pub owner: Signer<'info>,
5353

54-
#[account(has_one = share_mint)]
54+
#[account(
55+
has_one = share_mint,
56+
constraint = reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch,
57+
)]
5558
pub reserve: Account<'info, Reserve>,
5659

5760
pub share_mint: InterfaceAccount<'info, Mint>,

finance/lending/anchor/programs/lending/src/instructions/deposit_reserve_liquidity.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use anchor_spl::token_interface::{
44
};
55

66
use crate::errors::LendingError;
7-
use crate::math::{mul_div_floor, reserve_signer_seeds};
8-
use crate::state::Reserve;
7+
use crate::math::mul_div_floor;
8+
use crate::state::{reserve_signer_seeds, Reserve};
99

1010
/// Supply liquidity to a reserve and receive share tokens. The first deposit
1111
/// mints share tokens 1:1; later deposits mint

finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@ use anchor_spl::token_interface::{
33
transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,
44
};
55

6-
use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED};
6+
use crate::constants::{
7+
BPS_DENOMINATOR, FIXED_POINT_SCALE, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED,
8+
};
79
use crate::errors::LendingError;
810
use crate::math::{market_value, mul_div_ceil, mul_div_floor, value_to_amount, Rounding};
911
use crate::state::{Obligation, PriceFeed, Reserve};
1012

11-
/// Repay part of an unhealthy obligation's debt and seize a matching amount of
12-
/// its collateral share tokens plus a bonus. A single liquidation may repay at
13-
/// most the collateral reserve's close factor of the borrow. The seized
14-
/// collateral is valued at the borrow repaid plus the liquidation bonus, all
15-
/// rounded toward the borrower so the obligation is never over-seized.
13+
/// Repay part of an unhealthy obligation's debt and seize collateral share
14+
/// tokens worth the repayment plus the liquidation bonus.
15+
///
16+
/// The close factor caps how much of the borrow one call may repay; it comes
17+
/// from the repay (borrow) reserve because it is a property of the debt being
18+
/// closed. The liquidation bonus comes from the collateral reserve because it
19+
/// prices the collateral being seized. If the requested repayment would seize
20+
/// more collateral than the obligation holds, the call fails with
21+
/// `LiquidationTooLarge` — silently capping the seizure would make the
22+
/// liquidator pay full price for less collateral.
1623
///
1724
/// Self-liquidation (the owner liquidating their own position) is not blocked:
1825
/// it is only possible while unhealthy and is economically pointless, matching
@@ -28,64 +35,69 @@ pub fn handle_liquidate_obligation(
2835
context.accounts.repay_reserve.require_refreshed()?;
2936
context.accounts.collateral_reserve.require_refreshed()?;
3037

38+
let obligation = &context.accounts.obligation;
39+
let repay_reserve = &context.accounts.repay_reserve;
40+
let collateral_reserve = &context.accounts.collateral_reserve;
41+
3142
require!(
32-
context.accounts.obligation.borrowed_value > context.accounts.obligation.unhealthy_borrow_value,
43+
obligation.borrowed_value > obligation.unhealthy_borrow_value,
3344
LendingError::ObligationHealthy
3445
);
3546

36-
let repay_reserve_key = context.accounts.repay_reserve.key();
37-
let collateral_reserve_key = context.accounts.collateral_reserve.key();
3847
let repay_price = context.accounts.repay_price_feed.price_scaled(slot)?;
3948
let collateral_price = context.accounts.collateral_price_feed.price_scaled(slot)?;
4049

41-
let borrow_index = context.accounts.obligation.find_borrow(repay_reserve_key)?;
42-
let collateral_index = context.accounts.obligation.find_collateral(collateral_reserve_key)?;
43-
let borrowed_scaled = context.accounts.obligation.borrows[borrow_index].borrowed_scaled;
44-
let deposited_shares = context.accounts.obligation.deposits[collateral_index].deposited_shares;
50+
let borrow_index = obligation.find_borrow(repay_reserve.key())?;
51+
let collateral_index = obligation.find_collateral(collateral_reserve.key())?;
52+
let borrowed_scaled = obligation.borrows[borrow_index].borrowed_scaled;
53+
let deposited_shares = obligation.deposits[collateral_index].deposited_shares;
4554

4655
// How much debt this liquidation repays, capped by the close factor.
47-
let interest_index = context.accounts.repay_reserve.cumulative_borrow_rate_index;
56+
let interest_index = repay_reserve.cumulative_borrow_rate_index;
4857
let debt_now = mul_div_ceil(borrowed_scaled, interest_index, FIXED_POINT_SCALE)?;
4958
let debt_now = u64::try_from(debt_now).map_err(|_| LendingError::MathOverflow)?;
5059
let max_repay = mul_div_floor(
5160
debt_now as u128,
52-
context.accounts.collateral_reserve.config.close_factor_bps as u128,
61+
repay_reserve.config.close_factor_bps as u128,
5362
BPS_DENOMINATOR,
5463
)?;
5564
let repay = liquidity_amount.min(u64::try_from(max_repay).map_err(|_| LendingError::MathOverflow)?);
5665
require!(repay > 0, LendingError::ZeroAmount);
5766

5867
// Collateral to seize: value of the repayment plus the bonus, converted into
59-
// the collateral token and then into share tokens. Every step rounds down.
68+
// the collateral token and then into share tokens. Every step rounds down,
69+
// toward the borrower, so the obligation is never over-seized by rounding.
6070
let repay_value = market_value(
6171
repay,
62-
context.accounts.repay_reserve.liquidity_decimals,
72+
repay_reserve.liquidity_decimals,
6373
repay_price,
6474
Rounding::Down,
6575
)?;
6676
let bonus_value = mul_div_floor(
6777
repay_value,
68-
context.accounts.collateral_reserve.config.liquidation_bonus_bps as u128,
78+
collateral_reserve.config.liquidation_bonus_bps as u128,
6979
BPS_DENOMINATOR,
7080
)?;
7181
let seize_value = repay_value
7282
.checked_add(bonus_value)
7383
.ok_or(LendingError::MathOverflow)?;
7484
let seize_liquidity = value_to_amount(
7585
seize_value,
76-
context.accounts.collateral_reserve.liquidity_decimals,
86+
collateral_reserve.liquidity_decimals,
7787
collateral_price,
7888
Rounding::Down,
7989
)?;
8090
let seize_shares = mul_div_floor(
8191
seize_liquidity as u128,
82-
context.accounts.collateral_reserve.share_mint_supply as u128,
83-
context.accounts.collateral_reserve.total_liquidity()?.max(1),
92+
collateral_reserve.share_mint_supply as u128,
93+
collateral_reserve.total_liquidity()?.max(1),
8494
)?;
85-
let seize_shares = u64::try_from(seize_shares)
86-
.map_err(|_| LendingError::MathOverflow)?
87-
.min(deposited_shares);
95+
let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?;
8896
require!(seize_shares > 0, LendingError::ZeroAmount);
97+
require!(
98+
seize_shares <= deposited_shares,
99+
LendingError::LiquidationTooLarge
100+
);
89101

90102
let scaled_removed =
91103
mul_div_floor(repay as u128, FIXED_POINT_SCALE, interest_index)?.min(borrowed_scaled);
@@ -166,9 +178,15 @@ pub struct LiquidateObligation<'info> {
166178

167179
pub liquidator: Signer<'info>,
168180

169-
#[account(mut)]
181+
#[account(
182+
mut,
183+
constraint = repay_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch,
184+
)]
170185
pub repay_reserve: Box<Account<'info, Reserve>>,
171186

187+
#[account(
188+
constraint = collateral_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch,
189+
)]
172190
pub collateral_reserve: Box<Account<'info, Reserve>>,
173191

174192
#[account(address = repay_reserve.price_feed)]

finance/lending/anchor/programs/lending/src/instructions/redeem_reserve_collateral.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use anchor_spl::token_interface::{
44
};
55

66
use crate::errors::LendingError;
7-
use crate::math::{mul_div_floor, reserve_signer_seeds};
8-
use crate::state::Reserve;
7+
use crate::math::mul_div_floor;
8+
use crate::state::{reserve_signer_seeds, Reserve};
99

1010
/// Burn share tokens and withdraw the underlying liquidity they represent:
1111
/// `share_amount * total_liquidity / share_supply`, floored so the protocol

finance/lending/anchor/programs/lending/src/instructions/refresh_obligation.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::state::{Obligation, PriceFeed, Reserve};
1818
pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> Result<()> {
1919
let slot = Clock::get()?.slot;
2020
let obligation = &mut context.accounts.obligation;
21+
let lending_market = obligation.lending_market;
2122
let accounts = context.remaining_accounts;
2223
let mut cursor = 0usize;
2324

@@ -26,7 +27,8 @@ pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> Result<
2627
let mut unhealthy_borrow_value: u128 = 0;
2728

2829
for collateral in obligation.deposits.iter_mut() {
29-
let (reserve, price_scaled) = read_pair(accounts, &mut cursor, collateral.reserve, slot)?;
30+
let (reserve, price_scaled) =
31+
read_pair(accounts, &mut cursor, collateral.reserve, lending_market, slot)?;
3032

3133
let liquidity = mul_div_floor(
3234
collateral.deposited_shares as u128,
@@ -58,7 +60,8 @@ pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> Result<
5860

5961
let mut borrowed_value: u128 = 0;
6062
for borrow in obligation.borrows.iter_mut() {
61-
let (reserve, price_scaled) = read_pair(accounts, &mut cursor, borrow.reserve, slot)?;
63+
let (reserve, price_scaled) =
64+
read_pair(accounts, &mut cursor, borrow.reserve, lending_market, slot)?;
6265

6366
let debt = mul_div_ceil(
6467
borrow.borrowed_scaled,
@@ -89,12 +92,14 @@ pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> Result<
8992
}
9093

9194
/// Read the next `[reserve, price_feed]` pair from `remaining_accounts`,
92-
/// checking it matches the obligation's stored reserve and that both the
93-
/// reserve (refreshed this slot) and the price (fresh) are usable.
95+
/// checking it matches the obligation's stored reserve, belongs to the
96+
/// obligation's lending market, and that both the reserve (refreshed this
97+
/// slot) and the price (fresh) are usable.
9498
fn read_pair<'a, 'info>(
9599
accounts: &'a [AccountInfo<'info>],
96100
cursor: &mut usize,
97101
expected_reserve: Pubkey,
102+
lending_market: Pubkey,
98103
slot: u64,
99104
) -> Result<(Reserve, u128)>
100105
where
@@ -114,6 +119,11 @@ where
114119
LendingError::InvalidObligationAccount
115120
);
116121
let reserve = Account::<Reserve>::try_from(reserve_info)?;
122+
require_keys_eq!(
123+
reserve.lending_market,
124+
lending_market,
125+
LendingError::MarketMismatch
126+
);
117127
reserve.require_refreshed()?;
118128

119129
require_keys_eq!(

0 commit comments

Comments
 (0)