Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions finance/lending/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## 2026-09-23

Lock a minimum number of reserve shares. The first deposit now mints
`deposit - MINIMUM_SHARES` (1,000) shares, and every conversion between shares
and liquidity (deposit, redeem, collateral valuation in `refresh_obligation`,
`withdraw_obligation_collateral` and liquidation) divides by the share supply
plus that minimum, through the new `Reserve::total_shares`. The withheld shares
belong to nobody, so their slice of the pool stays locked. A first deposit of
`MINIMUM_SHARES` or less fails with `DepositTooSmall`, and a reserve whose
suppliers have all left prices the next deposit against the locked slice
rather than bootstrapping it.

Pricing shares against tracked `total_liquidity` stopped a vault donation, but
`total_liquidity` includes interest owed on borrows. A lone supplier with one
share could borrow a single base unit from their own reserve, let one second of
interest round the debt up to two, and then ratchet the share price up about
half again per round by depositing the most that still minted one share and
redeeming it, leaving the rounding with their own share. On the default rate
curve, with no bad debt and the loan repaid, 49 rounds made one share worth
about 690 USDC, and a 1,000 USDC deposit made after that minted one share, so
the attacker redeemed half the pool and kept 155 USDC of it. Tested by
`inflating_shares_through_own_borrow_does_not_pay`, which fails against the
previous program, and by `first_deposit_must_exceed_the_minimum` and
`sole_supplier_leaves_the_minimum_behind`. The test harness's `add_reserve` now
opens each reserve with a deposit from the market owner; `add_empty_reserve`
leaves it empty.

## 2026-09-22

Accrue interest by the wall clock instead of by slots. The reserve's
Expand Down
24 changes: 20 additions & 4 deletions finance/lending/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,26 @@ crosses the liquidation threshold and a liquidator can close part of the positio
### Share tokens (the deposit claim)

Supplying liquidity mints share tokens; redeeming burns them. The exchange rate
is `total_liquidity / share_supply`, where `total_liquidity = available_liquidity
+ current_debt`. `available_liquidity` (not the vault's raw token balance) is the
source of truth, so a token donated directly to the vault cannot inflate the rate,
closing the classic empty-pool inflation attack. The first deposit mints 1:1.
is `total_liquidity / total_shares`, where `total_liquidity = available_liquidity
+ current_debt` and `total_shares` is the share supply plus `MINIMUM_SHARES`.
`available_liquidity` (not the vault's raw token balance) is the source of truth,
so a token donated directly to the vault cannot inflate the rate.

That alone does not close the empty-pool inflation attack, because
`total_liquidity` also counts interest owed on borrows, and a supplier can
borrow. A lone supplier holding one share borrows a single base unit from their
own reserve; debt rounds up, so a second later it reads as two, and the share is
worth two units without anything having been minted. Deposits and redemptions
round in the pool's favor, so depositing the most that still mints one share
and redeeming it leaves the remainder with the only other share, the attacker's
own, and the price climbs about half again each round. After 49 rounds one share
is worth about 690 USDC, a 1,000 USDC deposit mints one share, and the attacker's
share redeems half the pool: 155 USDC of profit on a debt they repay. So the
first deposit mints 1:1 less `MINIMUM_SHARES` (1,000), which are never minted and
count as shares nobody holds in every conversion between shares and liquidity:
deposit, redeem, collateral valuation, collateral withdrawal and liquidation.
The attacker's one share is then 1 of 1,001, and what the rounding leaves behind
goes mostly to shares nobody can redeem.

### Interest: a kinked curve and a cumulative index

Expand Down
16 changes: 16 additions & 0 deletions finance/lending/anchor-v1/programs/lending/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ pub const SECONDS_PER_YEAR: u128 = 31_536_000;
/// time, so the window tightens on its own and never loosens.
pub const MAX_PRICE_STALENESS_SLOTS: u64 = 25;

/// Reserve shares withheld from a reserve's first deposit. The first supplier
/// receives `deposit - MINIMUM_SHARES` shares rather than the full amount, the
/// convention Uniswap V2 uses for LP tokens, and every conversion between
/// shares and liquidity counts these as shares that nobody holds
/// (`Reserve::total_shares`), so their slice of the pool never leaves.
///
/// Shares are priced off tracked `total_liquidity`, not the vault balance, so a
/// direct donation cannot move them. But `total_liquidity` counts interest owed
/// on borrows, and a supplier can also borrow: a lone supplier holding one
/// share can make it worth more than one unit through their own debt, then
/// ratchet the price up with deposits and redemptions whose rounding stays in
/// the pool, until a later deposit rounds down and they take part of it. With
/// the minimum counted, their one share is 1 of 1_001, and whatever the
/// rounding leaves behind is spread mostly across shares they cannot redeem.
pub const MINIMUM_SHARES: u64 = 1_000;

// PDA seeds.
pub const LENDING_MARKET_SEED: &[u8] = b"lending_market";
pub const RESERVE_SEED: &[u8] = b"reserve";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ use anchor_spl::token_interface::{
mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked,
};

use crate::constants::MINIMUM_SHARES;
use crate::errors::LendingError;
use crate::math::mul_div_floor;
use crate::state::{reserve_signer_seeds, Reserve};

/// Supply liquidity to a reserve and receive share tokens. The first deposit
/// mints share tokens 1:1; later deposits mint
/// `liquidity_amount * share_supply / total_liquidity`, floored so the protocol
/// keeps any rounding dust.
/// mints share tokens 1:1, less the `MINIMUM_SHARES` withheld; later deposits
/// mint `liquidity_amount * total_shares / total_liquidity`, where
/// `total_shares` counts the withheld minimum, floored so the protocol keeps
/// any rounding dust.
pub fn handle_deposit_reserve_liquidity(
context: Context<DepositReserveLiquidity>,
liquidity_amount: u64,
Expand All @@ -19,11 +21,31 @@ pub fn handle_deposit_reserve_liquidity(
let reserve = &mut context.accounts.reserve;
reserve.require_refreshed()?;

let share_supply = reserve.share_mint_supply as u128;
let share_amount = if share_supply == 0 {
liquidity_amount as u128
let total_liquidity = reserve.total_liquidity()?;
let share_amount = if reserve.share_mint_supply == 0 && total_liquidity == 0 {
// Bootstrap: shares track liquidity one-for-one, less the withheld
// minimum, so the share supply can never start at a dust amount.
liquidity_amount
.checked_sub(MINIMUM_SHARES)
.ok_or(LendingError::DepositTooSmall)? as u128
} else {
mul_div_floor(liquidity_amount as u128, share_supply, reserve.total_liquidity()?)?
// The withheld minimum counts as shares nobody holds, here and in every
// other conversion, so its slice of the pool is locked for good. That
// is what stops share inflation: a lone supplier who borrows from their
// own reserve can lift `total_liquidity` with the interest they owe,
// and deposits and redemptions that round in the pool's favour lift it
// further, until one share is worth enough to round a later deposit
// down. With the minimum counted, their one share is 1 of 1_001 and
// whatever they leave in the pool goes mostly to shares nobody redeems.
//
// A reserve whose suppliers have all left takes this branch too: the
// minimum's slice is still in `total_liquidity`, so the next deposit
// is priced against it rather than bootstrapped.
mul_div_floor(
liquidity_amount as u128,
reserve.total_shares()?,
total_liquidity,
)?
};
require!(share_amount > 0, LendingError::DepositTooSmall);
let share_amount = u64::try_from(share_amount).map_err(|_| LendingError::MathOverflow)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pub fn handle_liquidate_obligation(
)?;
let seize_shares = mul_div_floor(
seize_liquidity as u128,
collateral_reserve.share_mint_supply as u128,
collateral_reserve.total_shares()?,
collateral_reserve.total_liquidity()?.max(1),
)?;
let seize_shares = u64::try_from(seize_shares).map_err(|_| LendingError::MathOverflow)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use crate::math::mul_div_floor;
use crate::state::{reserve_signer_seeds, Reserve};

/// Burn share tokens and withdraw the underlying liquidity they represent:
/// `share_amount * total_liquidity / share_supply`, floored so the protocol
/// keeps any rounding dust. Capped by the reserve's available (un-borrowed)
/// `share_amount * total_liquidity / total_shares`, floored so the protocol
/// keeps any rounding dust. `total_shares` counts the `MINIMUM_SHARES` withheld
/// from the first deposit, as `deposit_reserve_liquidity` does, so their slice
/// of the pool never leaves. Capped by the reserve's available (un-borrowed)
/// liquidity.
pub fn handle_redeem_reserve_collateral(
context: Context<RedeemReserveCollateral>,
Expand All @@ -19,12 +21,11 @@ pub fn handle_redeem_reserve_collateral(
let reserve = &mut context.accounts.reserve;
reserve.require_refreshed()?;

let share_supply = reserve.share_mint_supply as u128;
require!(share_supply > 0, LendingError::InsufficientReserveLiquidity);
require!(reserve.share_mint_supply > 0, LendingError::InsufficientReserveLiquidity);
let liquidity_amount = mul_div_floor(
share_amount as u128,
reserve.total_liquidity()?,
share_supply,
reserve.total_shares()?,
)?;
let liquidity_amount = u64::try_from(liquidity_amount).map_err(|_| LendingError::MathOverflow)?;
require!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub fn handle_refresh_obligation(context: Context<RefreshObligation>) -> Result<
let liquidity = mul_div_floor(
collateral.deposited_shares as u128,
reserve.total_liquidity()?,
(reserve.share_mint_supply as u128).max(1),
reserve.total_shares()?,
)?;
let liquidity = u64::try_from(liquidity).map_err(|_| LendingError::MathOverflow)?;
let value = market_value(liquidity, reserve.liquidity_decimals, price_scaled, Rounding::Down)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub fn handle_withdraw_obligation_collateral(
let removed_liquidity = mul_div_ceil(
share_amount as u128,
reserve.total_liquidity()?,
(reserve.share_mint_supply as u128).max(1),
reserve.total_shares()?,
)?;
let removed_liquidity = u64::try_from(removed_liquidity).map_err(|_| LendingError::MathOverflow)?;
let removed_value = market_value(
Expand Down
17 changes: 15 additions & 2 deletions finance/lending/anchor-v1/programs/lending/src/state/reserve.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use anchor_lang::prelude::*;

use crate::constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE, RESERVE_SEED, SECONDS_PER_YEAR};
use crate::constants::{
BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, SECONDS_PER_YEAR,
};
use crate::errors::LendingError;
use crate::math::{mul_div_ceil, mul_div_floor};

Expand Down Expand Up @@ -52,7 +54,9 @@ pub struct Reserve {
/// burning share tokens directly via the token program (outside this
/// program) makes the real mint supply drift below this mirror; that drift
/// only lowers what the burner could have redeemed, so the pool never pays
/// out more than it holds.
/// out more than it holds. The `MINIMUM_SHARES` withheld from the first
/// deposit are never minted and are not counted here; `total_shares` adds
/// them.
pub share_mint_supply: u64,

/// Total borrowed principal. The live debt is
Expand Down Expand Up @@ -170,6 +174,15 @@ impl Reserve {
.ok_or(LendingError::MathOverflow.into())
}

/// The share count every conversion between shares and liquidity divides
/// by: the outstanding supply plus the `MINIMUM_SHARES` withheld from the
/// first deposit, which belong to nobody and so never redeem.
pub fn total_shares(&self) -> Result<u128> {
(self.share_mint_supply as u128)
.checked_add(MINIMUM_SHARES as u128)
.ok_or(LendingError::MathOverflow.into())
}

/// Borrowed fraction of the pool, in basis points (0..=10_000).
pub fn utilization_bps(&self) -> Result<u128> {
let gross = self.gross_liquidity()?;
Expand Down
43 changes: 33 additions & 10 deletions finance/lending/anchor-v1/programs/lending/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use solana_kite::{
use solana_signer::Signer;

use lending::constants::{
LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, OBLIGATION_SEED, OBLIGATION_SHARE_VAULT_SEED,
PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED,
LENDING_MARKET_SEED, LIQUIDITY_VAULT_SEED, MINIMUM_SHARES, OBLIGATION_SEED,
OBLIGATION_SHARE_VAULT_SEED, PRICE_FEED_SEED, RESERVE_SEED, SHARE_MINT_SEED,
};
use lending::state::{Obligation, Reserve, ReserveConfig};

Expand Down Expand Up @@ -274,11 +274,31 @@ impl Env {
send(&mut self.svm, vec![instruction], &[owner], &owner.pubkey()).unwrap();
}

/// Add a reserve to the default market and open it: the market owner makes
/// the first deposit, `MINIMUM_SHARES + 1`, so the withheld minimum is in
/// place and every later deposit mints shares one-for-one until interest
/// accrues. The owner keeps the one share it is minted.
pub fn add_reserve(
&mut self,
decimals: u8,
price_mantissa: i128,
config: ReserveConfig,
) -> ReserveHandle {
let handle = self.add_empty_reserve(decimals, price_mantissa, config);
let owner = self.owner.insecure_clone();
let opening_deposit = MINIMUM_SHARES + 1;
self.fund(&owner, handle.mint, opening_deposit);
self.supply(&owner, &handle, opening_deposit);
handle
}

/// Add a reserve to the default market with no deposits, for tests of the
/// first deposit itself.
pub fn add_empty_reserve(
&mut self,
decimals: u8,
price_mantissa: i128,
config: ReserveConfig,
) -> ReserveHandle {
let owner = self.owner.insecure_clone();
let market = self.market;
Expand Down Expand Up @@ -332,21 +352,24 @@ impl Env {
}

/// Supply liquidity to a reserve, receiving share tokens. Returns the user's
/// share-token account.
/// share-token account, which the first supply creates.
pub fn try_supply(
&mut self,
user: &Keypair,
handle: &ReserveHandle,
amount: u64,
) -> Result<Pubkey, String> {
let user_liquidity = ata(&user.pubkey(), &handle.mint);
let user_share = create_associated_token_account(
&mut self.svm,
&user.pubkey(),
&handle.share_mint,
user,
)
.unwrap();
let user_share = ata(&user.pubkey(), &handle.share_mint);
if self.svm.get_account(&user_share).is_none() {
create_associated_token_account(
&mut self.svm,
&user.pubkey(),
&handle.share_mint,
user,
)
.unwrap();
}

let deposit = Instruction {
program_id: lending::id(),
Expand Down
Loading
Loading